@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.
Files changed (71) hide show
  1. package/dist/auth-api.d.ts +22 -0
  2. package/dist/auth-api.js +69 -1
  3. package/dist/auth-api.js.map +1 -1
  4. package/dist/channel/adapters/discord.js +9 -0
  5. package/dist/channel/adapters/discord.js.map +1 -1
  6. package/dist/command-table.d.ts +8 -1
  7. package/dist/command-table.js +13 -4
  8. package/dist/command-table.js.map +1 -1
  9. package/dist/fleet-manager.d.ts +47 -0
  10. package/dist/fleet-manager.js +139 -5
  11. package/dist/fleet-manager.js.map +1 -1
  12. package/dist/locale.js +10 -0
  13. package/dist/locale.js.map +1 -1
  14. package/dist/public-web-gateway.js +2 -2
  15. package/dist/public-web-gateway.js.map +1 -1
  16. package/dist/topic-commands.js +3 -2
  17. package/dist/topic-commands.js.map +1 -1
  18. package/dist/ui/chat-render.js +6 -0
  19. package/dist/ui/chat-strings.js +17 -0
  20. package/dist/ui/chat-thread.js +22 -3
  21. package/dist/ui/image-lightbox.js +104 -0
  22. package/dist/ui/panel-chat.js +20 -52
  23. package/dist/ui/panel-details.js +146 -0
  24. package/dist/ui/panel-fleet.js +2 -1
  25. package/dist/ui/panel-settings.js +18 -0
  26. package/dist/ui/preview-panel.js +2 -1
  27. package/dist/ui/preview.js +49 -0
  28. package/dist/ui/settings-request.js +9 -0
  29. package/dist/ui/shared/agend-auth.js +15 -7
  30. package/dist/ui/shared/app-i18n.js +4 -2
  31. package/dist/ui/shared/app-route.js +7 -2
  32. package/dist/ui/shared/app-shell.js +22 -8
  33. package/dist/ui/shared/app.css +51 -1
  34. package/dist/ui/shared/app.js +6 -0
  35. package/dist/ui/shared/header-tools.js +93 -0
  36. package/dist/ui/shared/instance-nav.js +7 -4
  37. package/dist/ui/shared/instance-switch.js +16 -0
  38. package/dist/ui/shared/panel-view.js +14 -153
  39. package/dist/ui/shared/read-stream.js +26 -0
  40. package/dist/ui/shared/signin.js +38 -1
  41. package/dist/ui/shared/ui-icons.js +1 -0
  42. package/dist/ui/shared/usage-dialog.js +125 -0
  43. package/dist/ui/shared/view-strings.js +2 -2
  44. package/dist/ui/signin.html +3 -0
  45. package/dist/update-install.js +3 -1
  46. package/dist/update-install.js.map +1 -1
  47. package/dist/web-api.d.ts +16 -0
  48. package/dist/web-api.js +29 -4
  49. package/dist/web-api.js.map +1 -1
  50. package/dist/web-chat-history.d.ts +97 -6
  51. package/dist/web-chat-history.js +189 -12
  52. package/dist/web-chat-history.js.map +1 -1
  53. package/dist/web-chat-store.d.ts +45 -0
  54. package/dist/web-chat-store.js +192 -0
  55. package/dist/web-chat-store.js.map +1 -0
  56. package/dist/web-login.d.ts +2 -0
  57. package/dist/web-login.js +4 -0
  58. package/dist/web-login.js.map +1 -1
  59. package/dist/web-returning-device.d.ts +84 -0
  60. package/dist/web-returning-device.js +204 -0
  61. package/dist/web-returning-device.js.map +1 -0
  62. package/dist/web-shell-routes.d.ts +4 -0
  63. package/dist/web-shell-routes.js +9 -0
  64. package/dist/web-shell-routes.js.map +1 -1
  65. package/dist/web-upload.d.ts +21 -0
  66. package/dist/web-upload.js +50 -1
  67. package/dist/web-upload.js.map +1 -1
  68. package/launcher/old-updater-note.cjs +48 -0
  69. package/launcher/postinstall.cjs +8 -1
  70. package/package.json +1 -1
  71. 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
- /** The header every panel shows: ☰ (phone, or a collapsed sidebar), its title and status, then its actions. */
173
- export function PanelHeader({ title, sub, children, headingRef }) {
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 chatHref = route && route.panel === "chat" && route.instance ? chatPath(route.instance) : last ? chatPath(last) : "/ui";
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>`;
@@ -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; }
@@ -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
- const toView = viewOnly || (route && route.panel === "view");
198
- const active = route && (route.panel === "chat" || route.panel === "view") ? route.instance : null;
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=${toView ? viewPath(name) : chatPath(name)} title=${instanceTooltip(it)}
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
- const [usageAvailable, setUsageAvailable] = useState(false);
138
- const [density, setDensity] = useState(DENSITY[stored("agend_view_density")] ? stored("agend_view_density") : "fit");
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
- const cycleDensity = () => {
191
- const next = DENSITY_ORDER[(DENSITY_ORDER.indexOf(density) + 1) % DENSITY_ORDER.length];
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}>${actions}</${PanelHeader}>
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)) * (DENSITY[density] || 1);
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
+