@songsid/agend 2.2.0-alpha.3 → 2.2.0-alpha.5

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 (74) hide show
  1. package/dist/auth-api.js +4 -0
  2. package/dist/auth-api.js.map +1 -1
  3. package/dist/discord-general.d.ts +15 -0
  4. package/dist/discord-general.js +24 -0
  5. package/dist/discord-general.js.map +1 -0
  6. package/dist/fleet-manager.d.ts +9 -0
  7. package/dist/fleet-manager.js +22 -15
  8. package/dist/fleet-manager.js.map +1 -1
  9. package/dist/locale.js +8 -2
  10. package/dist/locale.js.map +1 -1
  11. package/dist/public-link-progress.d.ts +3 -1
  12. package/dist/public-link-progress.js +15 -1
  13. package/dist/public-link-progress.js.map +1 -1
  14. package/dist/public-web-gateway.js +3 -0
  15. package/dist/public-web-gateway.js.map +1 -1
  16. package/dist/public-web-link.d.ts +4 -2
  17. package/dist/public-web-link.js +10 -8
  18. package/dist/public-web-link.js.map +1 -1
  19. package/dist/quickstart-api.js +4 -1
  20. package/dist/quickstart-api.js.map +1 -1
  21. package/dist/topic-commands.js +4 -2
  22. package/dist/topic-commands.js.map +1 -1
  23. package/dist/tunnel/cloudflared-install.d.ts +25 -1
  24. package/dist/tunnel/cloudflared-install.js +181 -10
  25. package/dist/tunnel/cloudflared-install.js.map +1 -1
  26. package/dist/tunnel/deb-extract.d.ts +9 -0
  27. package/dist/tunnel/deb-extract.js +94 -0
  28. package/dist/tunnel/deb-extract.js.map +1 -0
  29. package/dist/ui/app.html +3 -0
  30. package/dist/ui/chat-strings.js +50 -0
  31. package/dist/ui/chat-thread.js +16 -1
  32. package/dist/ui/icons/apple-touch-icon.png +0 -0
  33. package/dist/ui/icons/favicon.ico +0 -0
  34. package/dist/ui/icons/favicon.svg +8 -0
  35. package/dist/ui/image-lightbox.js +104 -0
  36. package/dist/ui/panel-chat.js +16 -54
  37. package/dist/ui/panel-details.js +146 -0
  38. package/dist/ui/panel-fleet.js +2 -1
  39. package/dist/ui/panel-settings.js +18 -0
  40. package/dist/ui/preview-panel.js +9 -4
  41. package/dist/ui/preview.js +36 -13
  42. package/dist/ui/settings-request.js +9 -0
  43. package/dist/ui/shared/app-i18n.js +4 -2
  44. package/dist/ui/shared/app-route.js +7 -2
  45. package/dist/ui/shared/app-shell.js +22 -8
  46. package/dist/ui/shared/app.css +43 -0
  47. package/dist/ui/shared/app.js +6 -0
  48. package/dist/ui/shared/header-tools.js +93 -0
  49. package/dist/ui/shared/instance-nav.js +7 -4
  50. package/dist/ui/shared/instance-switch.js +16 -0
  51. package/dist/ui/shared/panel-view.js +14 -153
  52. package/dist/ui/shared/read-stream.js +26 -0
  53. package/dist/ui/shared/ui-icons.js +1 -0
  54. package/dist/ui/shared/usage-dialog.js +125 -0
  55. package/dist/ui/shared/view-strings.js +2 -2
  56. package/dist/ui/signin.html +3 -0
  57. package/dist/update-install.js +3 -1
  58. package/dist/update-install.js.map +1 -1
  59. package/dist/web-api.d.ts +16 -0
  60. package/dist/web-api.js +31 -4
  61. package/dist/web-api.js.map +1 -1
  62. package/dist/web-icons.d.ts +21 -0
  63. package/dist/web-icons.js +38 -0
  64. package/dist/web-icons.js.map +1 -0
  65. package/dist/web-preview.d.ts +9 -0
  66. package/dist/web-preview.js +15 -9
  67. package/dist/web-preview.js.map +1 -1
  68. package/dist/web-shell-routes.d.ts +4 -0
  69. package/dist/web-shell-routes.js +9 -0
  70. package/dist/web-shell-routes.js.map +1 -1
  71. package/launcher/old-updater-note.cjs +48 -0
  72. package/launcher/postinstall.cjs +8 -1
  73. package/package.json +1 -1
  74. package/scripts/preinstall-guard.cjs +5 -2
@@ -25,7 +25,18 @@
25
25
  var NEVER_KEY = "agend_html_preview_never"; // sessionStorage: "1" — never preview on this device (this session)
26
26
  var BANNER = "Previews run the agent's HTML in an isolated frame. It cannot use your login, but it may be able to send data out. Only preview content you trust. A preview can slow or freeze this tab.";
27
27
 
28
- var cfg = { dashboardOrigin: "", previewOrigin: "", boot: "", reason: "" };
28
+ var cfg = { dashboardOrigin: "", previewOrigin: "", boot: "", reason: "", reasonCode: "" };
29
+ // #1554: the page's words. `say(key, english, …values)` asks the app's dictionary (init's opts.text) and keeps the
30
+ // English here when it has none — so this file still works, and reads the same, without the app.
31
+ var text = null;
32
+ function say(key, english) {
33
+ var values = Array.prototype.slice.call(arguments, 2);
34
+ var s = null;
35
+ if (text) { try { s = text.apply(null, [key].concat(values)); } catch (e) { s = null; } }
36
+ if (typeof s === "string" && s && s !== key) return s;
37
+ for (var i = 0; i < values.length; i++) english = english.split("{" + i + "}").join(String(values[i]));
38
+ return english;
39
+ }
29
40
  var changed = []; // the page's "this device's choice changed" callbacks (checkbox, cards)
30
41
  var live = new Map(); // cardKey → { key, iframe, ch, holder, state, … } (at most one: one running preview per page)
31
42
  var listening = false;
@@ -41,14 +52,19 @@
41
52
  return s;
42
53
  }
43
54
 
44
- /** What the server said about this page load (the <body data-*> attributes /ui was served with). */
45
- function init(data) {
55
+ /**
56
+ * What the server said about this page load (the <body data-*> attributes /ui was served with). opts.text(key,
57
+ * …values): the app's dictionary for this file's words (#1554); without it they are the English below.
58
+ */
59
+ function init(data, opts) {
46
60
  cfg = {
47
61
  dashboardOrigin: String((data && data.dashboardOrigin) || ""),
48
62
  previewOrigin: String((data && data.previewOrigin) || ""),
49
63
  boot: String((data && data.previewBoot) || ""),
50
64
  reason: String((data && data.previewReason) || ""),
65
+ reasonCode: String((data && data.previewReasonCode) || ""),
51
66
  };
67
+ if (opts && typeof opts.text === "function") text = opts.text;
52
68
  if (!listening && typeof root.addEventListener === "function") {
53
69
  root.addEventListener("message", onMessage);
54
70
  // The opt-in is per device: another tab of this dashboard turning it off (or clearing storage) stops the
@@ -96,13 +112,18 @@
96
112
  * a frame from the wrong place), and the device must have opted in and not switched previews off.
97
113
  */
98
114
  function availability() {
99
- if (!cfg.previewOrigin || !cfg.boot) return { ok: false, why: "server", reason: cfg.reason || "Previews are not available here." };
115
+ if (!cfg.previewOrigin || !cfg.boot) {
116
+ // The server's reason, in the page's language when the server named it (a code), else as the server wrote it.
117
+ var server = cfg.reasonCode ? say("pvServer_" + cfg.reasonCode, cfg.reason || "Previews are not available here.", cfg.dashboardOrigin) : "";
118
+ return { ok: false, why: "server", reason: server || cfg.reason || say("pvWhyServer", "Previews are not available here.") };
119
+ }
100
120
  var at = root.location && root.location.origin;
101
121
  if (!cfg.dashboardOrigin || at !== cfg.dashboardOrigin) {
102
- return { ok: false, why: "origin", reason: "Previews are off: this page is at " + at + ", but the fleet believes it is at " + (cfg.dashboardOrigin || "an unknown address") + " (a proxy that rewrites Host). Pass the external Host through, or set web.preview_origin." };
122
+ return { ok: false, why: "origin", reason: say("pvWhyOrigin", "Previews are off: this page is at {0}, but the fleet believes it is at {1} (a proxy that rewrites Host). Pass the external Host through, or set web.preview_origin.",
123
+ at, cfg.dashboardOrigin || say("pvUnknownAddress", "an unknown address")) };
103
124
  }
104
- if (never()) return { ok: false, why: "never", reason: "Previews are switched off on this device for this session." };
105
- if (!optedIn()) return { ok: false, why: "optin", reason: "Previews are off on this device. Allow them from the card's menu or the sidebar." };
125
+ if (never()) return { ok: false, why: "never", reason: say("pvWhyNever", "Previews are switched off on this device for this session.") };
126
+ if (!optedIn()) return { ok: false, why: "optin", reason: say("pvWhyOptin", "Previews are off on this device. Allow them from the card's menu or the sidebar.") };
106
127
  return { ok: true, why: "", reason: "" };
107
128
  }
108
129
 
@@ -134,7 +155,7 @@
134
155
  function start(key, holder, html, ui, opts) {
135
156
  var a = availability();
136
157
  if (!a.ok) { ui.state("unavailable", a.reason); return false; }
137
- if (typeof html !== "string" || utf8Bytes(html) > LIMITS.maxBytes) { ui.state("unavailable", "This HTML is over 1 MiB; it is not previewed."); return false; }
158
+ if (typeof html !== "string" || utf8Bytes(html) > LIMITS.maxBytes) { ui.state("unavailable", say("pvHtmlOver", "This HTML is over 1 MiB; it is not previewed.")); return false; }
138
159
  stopAll("another");
139
160
  var doc = holder.ownerDocument;
140
161
  var iframe = mountPreview(doc);
@@ -146,7 +167,7 @@
146
167
  if (card.fill) iframe.classList.add("fill");
147
168
  live.set(key, card);
148
169
  card.readyTimer = root.setTimeout(function () {
149
- if (live.get(key) === card && !card.rendered) stop(key, "unavailable", "Preview unavailable: the preview did not answer within 3 s. If an earlier preview froze, it may still be running — open the dashboard in a new tab. Over SSH, forward the preview port too.");
170
+ if (live.get(key) === card && !card.rendered) stop(key, "unavailable", say("pvNoAnswer", "Preview unavailable: the preview did not answer within 3 s. If an earlier preview froze, it may still be running — open the dashboard in a new tab. Over SSH, forward the preview port too."));
150
171
  }, LIMITS.readyMs);
151
172
  holder.appendChild(iframe);
152
173
  if (!card.fill) setHeight(card, LIMITS.minHeight);
@@ -163,7 +184,7 @@
163
184
  // Best effort: a frame that froze this tab freezes this timer too. Only while the page is visible.
164
185
  var hidden = root.document && root.document.visibilityState === "hidden";
165
186
  if (hidden) { armWatchdog(card); return; }
166
- stop(card.key, "stopped", "The preview stopped answering, so it was closed.");
187
+ stop(card.key, "stopped", say("pvStoppedAnswering", "The preview stopped answering, so it was closed."));
167
188
  }, LIMITS.watchdogMs);
168
189
  }
169
190
 
@@ -181,7 +202,7 @@
181
202
  function stopAll(why) {
182
203
  var keys = [];
183
204
  live.forEach(function (_c, k) { keys.push(k); });
184
- for (var i = 0; i < keys.length; i++) stop(keys[i], "stopped", why === "off" ? "Previews were switched off on this device." : why === "another" ? "Another preview started." : "");
205
+ for (var i = 0; i < keys.length; i++) stop(keys[i], "stopped", why === "off" ? say("pvSwitchedOff", "Previews were switched off on this device.") : why === "another" ? say("pvAnother", "Another preview started.") : "");
185
206
  return keys;
186
207
  }
187
208
  /** Stop every preview inside `node` (before the chat replaces, moves or removes it); returns the stopped keys. */
@@ -214,7 +235,7 @@
214
235
  if (Object.keys(d).sort().join(",") !== KEYS[d.type]) return;
215
236
  if (d.type === "ready") {
216
237
  if (d.ch !== null || typeof d.boot !== "string" || card.rendered) return;
217
- if (d.boot !== cfg.boot) { stop(card.key, "unavailable", "Preview unavailable: something else answered on the preview port."); return; }
238
+ if (d.boot !== cfg.boot) { stop(card.key, "unavailable", say("pvWrongBoot", "Preview unavailable: something else answered on the preview port.")); return; }
218
239
  if (!availability().ok) { stop(card.key, "unavailable", availability().reason); return; }
219
240
  card.rendered = true;
220
241
  root.clearTimeout(card.readyTimer); card.readyTimer = null;
@@ -227,7 +248,7 @@
227
248
  if (d.type === "heartbeat") {
228
249
  // A heartbeat never outlives the permission: if this device stopped allowing previews (and the storage event
229
250
  // was missed), the next heartbeat ends it.
230
- if (!availability().ok) { stop(card.key, "stopped", "Previews were switched off on this device."); return; }
251
+ if (!availability().ok) { stop(card.key, "stopped", say("pvSwitchedOff", "Previews were switched off on this device.")); return; }
231
252
  armWatchdog(card);
232
253
  return;
233
254
  }
@@ -266,5 +287,7 @@
266
287
  init: init, availability: availability, optedIn: optedIn, setOptIn: setOptIn, never: never, setNever: setNever, onChange: onChange, listeners: listeners, onStorage: onStorage,
267
288
  start: start, stop: stop, stopAll: stopAll, stopIn: stopIn, running: running, liveCount: liveCount, liveFrame: liveFrame,
268
289
  mountPreview: mountPreview, onMessage: onMessage, BANNER: BANNER, LIMITS: LIMITS,
290
+ /** The banner every running preview carries, in the page's language (#1554); BANNER is its English. */
291
+ banner: function () { return say("pvBanner", BANNER); },
269
292
  };
270
293
  });
@@ -0,0 +1,9 @@
1
+ // #1523 N2: another page asks Settings to open one instance's dialog ("Edit in Settings" on Details). One request,
2
+ // taken once by Settings when its data is in; it opens only what Settings itself knows (its own config, its own
3
+ // rules): an agent of fleet.yaml, or a ClassicBot room of classicBot.yaml (#1561 review: the split stays).
4
+ let wanted = null;
5
+ /** kind: "agent" | "classic". */
6
+ export function requestAgentSettings(name, kind = "agent") {
7
+ wanted = typeof name === "string" && name ? { name, kind: kind === "classic" ? "classic" : "agent" } : null;
8
+ }
9
+ export function takeAgentRequest() { const w = wanted; wanted = null; return w; }
@@ -42,7 +42,8 @@ register("app", {
42
42
  collapse: "Hide the sidebar", expand: "Show the sidebar", openMenu: "Open the sidebar", closeMenu: "Close the sidebar",
43
43
  theme: "Theme", themeSystem: "System", themeLight: "Light", themeDark: "Dark", language: "Language",
44
44
  previews: "Allow HTML previews on this device", tour: "Tour", tourTitle: "Show the web chat tour again",
45
- needsYou: "needs you", approxNote: "Read from the terminal, so approximate", chat: "Chat",
45
+ needsYou: "needs you", approxNote: "Read from the terminal, so approximate", chat: "Chat", details: "Details", instanceViews: "Views of {0}",
46
+ textSize: "Text size: {0}", textSize_s: "S", textSize_m: "M", textSize_l: "L", textSize_fit: "Fit", usage: "Usage",
46
47
  statusRunning: "running", statusStopped: "stopped", statusCrashed: "crashed", statusWorking: "working", statusStuck: "looks stuck", statusIdle: "idle",
47
48
  connPolling: "Live updates paused — refreshing every 5 s", connDown: "Disconnected — trying again…", connConnecting: "Connecting…",
48
49
  hydrating: "Loading what is open now…", hydrateFailed: "Could not load what is open now (prompts, ticks).",
@@ -70,7 +71,8 @@ register("app", {
70
71
  collapse: "隱藏側邊欄", expand: "顯示側邊欄", openMenu: "打開側邊欄", closeMenu: "關閉側邊欄",
71
72
  theme: "主題", themeSystem: "跟隨系統", themeLight: "淺色", themeDark: "深色", language: "語言",
72
73
  previews: "允許這台裝置預覽 HTML", tour: "導覽", tourTitle: "再看一次網頁聊天導覽",
73
- needsYou: "等你回應", approxNote: "從終端畫面判讀,僅供參考", chat: "聊天",
74
+ needsYou: "等你回應", approxNote: "從終端畫面判讀,僅供參考", chat: "聊天", details: "詳細資訊", instanceViews: "{0} 的檢視",
75
+ textSize: "字級:{0}", textSize_s: "小", textSize_m: "中", textSize_l: "大", textSize_fit: "剛好填滿", usage: "用量",
74
76
  statusRunning: "執行中", statusStopped: "已停止", statusCrashed: "已當掉", statusWorking: "工作中", statusStuck: "似乎卡住", statusIdle: "閒置",
75
77
  connPolling: "即時更新暫停中——每 5 秒重新整理", connDown: "已斷線——正在重試…", connConnecting: "連線中…",
76
78
  hydrating: "正在載入目前的狀態…", hydrateFailed: "無法載入目前的狀態(提示、送達標記)。",
@@ -13,7 +13,7 @@ export function isSafeInstanceName(name) {
13
13
  }
14
14
 
15
15
  /**
16
- * A path → the route it names: { panel: "chat" | "view", instance } | { panel: "fleet", tab } |
16
+ * A path → the route it names: { panel: "chat" | "view" | "details", instance } | { panel: "fleet", tab } |
17
17
  * { panel: "settings", section } | null (not a page of the app: a full load). A malformed name is null too; the
18
18
  * server answers it 400.
19
19
  */
@@ -21,7 +21,10 @@ export function parseRoute(pathname) {
21
21
  if (pathname === "/ui") return { panel: "chat", instance: null };
22
22
  if (pathname === "/ui/fleet") return { panel: "fleet", tab: "tasks" };
23
23
  if (pathname === "/ui/needs") return { panel: "needs" };
24
- let m = /^\/ui\/fleet\/([^/]+)$/.exec(pathname);
24
+ // #1523 N2: one instance's Details (`agent` is no Fleet tab; a tab is one segment).
25
+ let m = /^\/ui\/fleet\/agent\/([^/]+)$/.exec(pathname);
26
+ if (m) return named("details", m[1]);
27
+ m = /^\/ui\/fleet\/([^/]+)$/.exec(pathname);
25
28
  if (m) return FLEET_TABS.includes(m[1]) ? { panel: "fleet", tab: m[1] } : null;
26
29
  m = /^\/ui\/chat\/([^/]+)$/.exec(pathname);
27
30
  if (m) return named("chat", m[1]);
@@ -41,6 +44,7 @@ function named(panel, segment) {
41
44
 
42
45
  export function chatPath(instance) { return `/ui/chat/${encodeURIComponent(instance)}`; }
43
46
  export function viewPath(instance) { return instance ? `/view/${encodeURIComponent(instance)}` : "/view"; }
47
+ export function detailsPath(instance) { return `/ui/fleet/agent/${encodeURIComponent(instance)}`; }
44
48
  export function fleetPath(tab) { return tab && tab !== "tasks" ? `/ui/fleet/${tab}` : "/ui/fleet"; }
45
49
  export const NEEDS_PATH = "/ui/needs";
46
50
  export function settingsPath(section) { return section && section !== "agents" ? `/settings/${section}` : "/settings"; }
@@ -50,6 +54,7 @@ export function routePath(route) {
50
54
  if (route.panel === "settings") return settingsPath(route.section);
51
55
  if (route.panel === "needs") return NEEDS_PATH;
52
56
  if (route.panel === "view") return viewPath(route.instance);
57
+ if (route.panel === "details") return detailsPath(route.instance);
53
58
  return route.instance ? chatPath(route.instance) : "/ui";
54
59
  }
55
60
  /** One string per route: a change of it is a new navigation (a new lease for the panel, §4). */
@@ -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,26 @@ 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
+ /* A chat image, full size, in the page (image-lightbox.js): the whole viewport, dark, the image fitted to it. */
294
+ html.lb-open .scroller { overflow: hidden; }
295
+ .lb { position: fixed; inset: 0; width: 100vw; height: 100vh; height: 100dvh; max-width: none; max-height: none; margin: 0; padding: 0; border: 0;
296
+ background: rgba(0, 0, 0, 0.95); color: #fff; overflow: hidden; overscroll-behavior: contain; }
297
+ .lb::backdrop { background: rgba(0, 0, 0, 0.6); }
298
+ .lb-stage { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 56px 16px 16px; box-sizing: border-box; }
299
+ .lb-img { max-width: 100%; max-height: 100%; object-fit: contain; display: block; }
300
+ .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);
301
+ background: rgba(0, 0, 0, 0.75); }
302
+ .lb-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-sm); }
303
+ .lb-count { font-size: var(--fs-sm); opacity: 0.85; }
304
+ .lb .icon-btn { color: #fff; }
305
+ .lb .icon-btn:hover { background: rgba(255, 255, 255, 0.15); }
306
+ .lb .lb-orig { color: #fff; background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.3); }
307
+ .lb-nav { position: absolute; top: 50%; transform: translateY(-50%); z-index: 1; background: rgba(0, 0, 0, 0.4); }
308
+ .lb-prev { left: var(--s-2); }
309
+ .lb-next { right: var(--s-2); }
310
+ /* A phone: the arrows sit below the image's middle band, out of its way, still reachable with a thumb. */
311
+ @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
312
  .msg .att-file { font-size: var(--fs-sm); color: var(--accent); }
270
313
  .msg .att-size { color: var(--text-3); }
271
314
  .msg .md p { margin: 0 0 var(--s-3); } .msg .md p:last-child { margin-bottom: 0; }
@@ -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
+ }