@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.
- package/dist/auth-api.js +4 -0
- package/dist/auth-api.js.map +1 -1
- package/dist/discord-general.d.ts +15 -0
- package/dist/discord-general.js +24 -0
- package/dist/discord-general.js.map +1 -0
- package/dist/fleet-manager.d.ts +9 -0
- package/dist/fleet-manager.js +22 -15
- package/dist/fleet-manager.js.map +1 -1
- package/dist/locale.js +8 -2
- package/dist/locale.js.map +1 -1
- package/dist/public-link-progress.d.ts +3 -1
- package/dist/public-link-progress.js +15 -1
- package/dist/public-link-progress.js.map +1 -1
- package/dist/public-web-gateway.js +3 -0
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/public-web-link.d.ts +4 -2
- package/dist/public-web-link.js +10 -8
- package/dist/public-web-link.js.map +1 -1
- package/dist/quickstart-api.js +4 -1
- package/dist/quickstart-api.js.map +1 -1
- package/dist/topic-commands.js +4 -2
- package/dist/topic-commands.js.map +1 -1
- package/dist/tunnel/cloudflared-install.d.ts +25 -1
- package/dist/tunnel/cloudflared-install.js +181 -10
- package/dist/tunnel/cloudflared-install.js.map +1 -1
- package/dist/tunnel/deb-extract.d.ts +9 -0
- package/dist/tunnel/deb-extract.js +94 -0
- package/dist/tunnel/deb-extract.js.map +1 -0
- package/dist/ui/app.html +3 -0
- package/dist/ui/chat-strings.js +50 -0
- package/dist/ui/chat-thread.js +16 -1
- package/dist/ui/icons/apple-touch-icon.png +0 -0
- package/dist/ui/icons/favicon.ico +0 -0
- package/dist/ui/icons/favicon.svg +8 -0
- package/dist/ui/image-lightbox.js +104 -0
- package/dist/ui/panel-chat.js +16 -54
- 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 +9 -4
- package/dist/ui/preview.js +36 -13
- package/dist/ui/settings-request.js +9 -0
- 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 +43 -0
- 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/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 +31 -4
- package/dist/web-api.js.map +1 -1
- package/dist/web-icons.d.ts +21 -0
- package/dist/web-icons.js +38 -0
- package/dist/web-icons.js.map +1 -0
- package/dist/web-preview.d.ts +9 -0
- package/dist/web-preview.js +15 -9
- package/dist/web-preview.js.map +1 -1
- 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/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
package/dist/ui/preview.js
CHANGED
|
@@ -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
|
-
/**
|
|
45
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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,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; }
|
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
|
+
}
|