@songsid/agend 2.2.0-alpha.4 → 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 (38) hide show
  1. package/dist/auth-api.js +4 -0
  2. package/dist/auth-api.js.map +1 -1
  3. package/dist/fleet-manager.d.ts +9 -0
  4. package/dist/fleet-manager.js +11 -0
  5. package/dist/fleet-manager.js.map +1 -1
  6. package/dist/ui/chat-strings.js +13 -0
  7. package/dist/ui/chat-thread.js +15 -0
  8. package/dist/ui/image-lightbox.js +104 -0
  9. package/dist/ui/panel-chat.js +13 -52
  10. package/dist/ui/panel-details.js +146 -0
  11. package/dist/ui/panel-fleet.js +2 -1
  12. package/dist/ui/panel-settings.js +18 -0
  13. package/dist/ui/settings-request.js +9 -0
  14. package/dist/ui/shared/app-i18n.js +4 -2
  15. package/dist/ui/shared/app-route.js +7 -2
  16. package/dist/ui/shared/app-shell.js +22 -8
  17. package/dist/ui/shared/app.css +43 -0
  18. package/dist/ui/shared/app.js +6 -0
  19. package/dist/ui/shared/header-tools.js +93 -0
  20. package/dist/ui/shared/instance-nav.js +7 -4
  21. package/dist/ui/shared/instance-switch.js +16 -0
  22. package/dist/ui/shared/panel-view.js +14 -153
  23. package/dist/ui/shared/read-stream.js +26 -0
  24. package/dist/ui/shared/ui-icons.js +1 -0
  25. package/dist/ui/shared/usage-dialog.js +125 -0
  26. package/dist/ui/shared/view-strings.js +2 -2
  27. package/dist/update-install.js +3 -1
  28. package/dist/update-install.js.map +1 -1
  29. package/dist/web-api.d.ts +16 -0
  30. package/dist/web-api.js +29 -4
  31. package/dist/web-api.js.map +1 -1
  32. package/dist/web-shell-routes.d.ts +4 -0
  33. package/dist/web-shell-routes.js +9 -0
  34. package/dist/web-shell-routes.js.map +1 -1
  35. package/launcher/old-updater-note.cjs +48 -0
  36. package/launcher/postinstall.cjs +8 -1
  37. package/package.json +1 -1
  38. package/scripts/preinstall-guard.cjs +5 -2
@@ -32,6 +32,7 @@ register("chat", {
32
32
  pvOpenPanel: "Open in panel", pvInPanel: "Shown in the panel.", pvPanel: "HTML preview", pvSource: "From {0}, {1} — show the message",
33
33
  pvPage: "Page", pvCode: "Code", pvReload: "Reload", pvClose: "Close the panel", pvBack: "Back to the chat", pvResize: "Resize the preview panel",
34
34
  pvNewer: "A newer version is in the reply from {0}.", pvNewerShow: "Show it", pvPanelIdle: "Click Preview to run it here.",
35
+ lbLabel: "Image: {0}", lbLabelNoName: "Image", lbClose: "Close", lbOpenOriginal: "Open original", lbPrev: "Previous image", lbNext: "Next image", lbCount: "{0} / {1}",
35
36
  // #1554: preview.js's words (its English is the fallback there), and the server's reasons by code.
36
37
  pvWhyOptin: "HTML previews are off on this device (this browser, at this address). Allow them from this card's ⋯ menu or the sidebar.",
37
38
  pvWhyOptinPanel: "HTML previews are off on this device (this browser, at this address).",
@@ -65,6 +66,12 @@ register("chat", {
65
66
  loadFailed: "Failed to load", tour: "Tour", tourBtnTitle: "Show the web chat tour again",
66
67
  dName: "Name", dStatus: "Status", dDisplay: "Display name", dDescription: "Description", dDirectory: "Directory", dBackend: "Backend", dModel: "Model", dEffort: "Effort",
67
68
  dCost: "Cost", dCostNote: "(session accumulated)", dContext: "Context", dRate5: "Rate 5h", dRate7: "Rate 7d", dActivity: "Recent activity", dInstance: "Instance", dRuntime: "Runtime",
69
+ // #1523 N2: Details (the Fleet side of one instance).
70
+ dConfig: "Configuration", dBinding: "Bound to", dBindingGeneral: "the General topic of {0}", dBindingTopic: "{0}, topic {1}", dBindingNoTopic: "{0}, no topic yet",
71
+ dBindingDefault: "{0} (the first connection, by default)", dBindingRoom: "ClassicBot room #{0} ({1})",
72
+ dBindingNone: "not bound (web only)", dPrimary: "the first connection", dTags: "Tags", dReadOnly: "Read only — change it in Settings.",
73
+ dEditInSettings: "Edit in Settings", dPause: "Pause", dWake: "Wake", dPaused: "{0} paused", dWoken: "{0} is waking up", dActionFailed: "That did not work — try again.",
74
+ dNoActivity: "No activity in the last hour.", dWorking: "Working…",
68
75
  configured: "(configured)", fleetDefault: "(fleet default)",
69
76
  },
70
77
  "zh-TW": {
@@ -97,6 +104,7 @@ register("chat", {
97
104
  pvOpenPanel: "在面板中開啟", pvInPanel: "已在面板中顯示。", pvPanel: "HTML 預覽", pvSource: "來自 {0},{1}——顯示該訊息",
98
105
  pvPage: "頁面", pvCode: "程式碼", pvReload: "重新載入", pvClose: "關閉面板", pvBack: "回到對話", pvResize: "調整預覽面板寬度",
99
106
  pvNewer: "{0} 的回覆裡有較新的版本。", pvNewerShow: "顯示", pvPanelIdle: "按「預覽」在這裡執行。",
107
+ lbLabel: "圖片:{0}", lbLabelNoName: "圖片", lbClose: "關閉", lbOpenOriginal: "開啟原圖", lbPrev: "上一張", lbNext: "下一張", lbCount: "{0} / {1}",
100
108
  pvWhyOptin: "這台裝置(這個瀏覽器、這個網址)還沒允許 HTML 預覽。可從這張卡片的 ⋯ 選單或側欄允許。",
101
109
  pvWhyOptinPanel: "這台裝置(這個瀏覽器、這個網址)還沒允許 HTML 預覽。",
102
110
  pvWhyNever: "這台裝置在這個工作階段已關閉預覽。",
@@ -129,6 +137,11 @@ register("chat", {
129
137
  loadFailed: "載入失敗", tour: "導覽", tourBtnTitle: "再看一次網頁聊天導覽",
130
138
  dName: "名稱", dStatus: "狀態", dDisplay: "顯示名稱", dDescription: "說明", dDirectory: "目錄", dBackend: "Backend", dModel: "模型", dEffort: "Effort",
131
139
  dCost: "花費", dCostNote: "(本次 session 累計)", dContext: "Context", dRate5: "5 小時額度", dRate7: "7 天額度", dActivity: "最近活動", dInstance: "Instance", dRuntime: "執行環境",
140
+ dConfig: "設定", dBinding: "綁定", dBindingGeneral: "{0} 的 General topic", dBindingTopic: "{0},topic {1}", dBindingNoTopic: "{0},尚未綁定 topic",
141
+ dBindingDefault: "{0}(預設:第一個連線)", dBindingRoom: "ClassicBot 房間 #{0}({1})",
142
+ dBindingNone: "未綁定(只用網頁)", dPrimary: "第一個連線", dTags: "標籤", dReadOnly: "這裡只能檢視——請到設定修改。",
143
+ dEditInSettings: "到設定編輯", dPause: "暫停", dWake: "喚醒", dPaused: "{0} 已暫停", dWoken: "{0} 正在喚醒", dActionFailed: "沒有成功,請再試一次。",
144
+ dNoActivity: "最近一小時沒有活動。", dWorking: "處理中…",
132
145
  configured: "(設定值)", fleetDefault: "(fleet 預設)",
133
146
  },
134
147
  });
@@ -274,8 +274,23 @@ export function createThread(list, scroller, opts) {
274
274
  button.textContent = ok ? tr("chat.copied") : tr("chat.copyFailed");
275
275
  setTimeout(() => { button.textContent = was; }, 1200);
276
276
  }
277
+ /**
278
+ * A chat image: a plain left click (or Enter on it) shows it in the page — this message's images, from the one
279
+ * clicked. With a modifier key the link does what the browser does (a new tab, a new window, a download); a middle
280
+ * click is not a click event at all. True when it was an image (whether or not it opened).
281
+ */
282
+ function onImageClick(e) {
283
+ const img = e.target && e.target.closest ? e.target.closest("a.att-img") : null;
284
+ if (!img || !list.contains(img)) return false;
285
+ if (opts.openImage && !e.defaultPrevented && (e.button ?? 0) === 0 && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey) {
286
+ const links = [...(img.closest(".atts") || img).querySelectorAll("a.att-img")];
287
+ if (opts.openImage(links.length ? links : [img], img)) e.preventDefault();
288
+ }
289
+ return true;
290
+ }
277
291
  /** The thread's own buttons (Copy, Wrap, fold), delegated from the thread element. */
278
292
  function onClick(e) {
293
+ if (onImageClick(e)) return;
279
294
  const el = e.target && e.target.closest ? e.target.closest("[data-act]") : null;
280
295
  if (!el || !list.contains(el)) return;
281
296
  const act = el.dataset.act;
@@ -0,0 +1,104 @@
1
+ // A chat image, full size, in the page: a dark overlay with the image fitted to the viewport, ✕ to close, a click on
2
+ // the dark area or Esc closes it too, and focus goes back to the thumbnail. "Open original" opens the same URL in a
3
+ // new tab (the thumbnail's link, unchanged: a ctrl-, meta-, shift- or middle-click on it still does what the browser
4
+ // does). Several images in one message: previous / next, and the arrow keys.
5
+ //
6
+ // The native <dialog> opened with showModal(): the browser traps focus and makes the rest inert. No style attribute
7
+ // (#1300): classes only. Only a file URL the chat itself rendered (/ui/file/<32 hex>) is ever shown.
8
+ import { html, useEffect, useLayoutEffect, useRef, useState } from "/assets/app-html.js";
9
+ import { t } from "/assets/app-i18n.js";
10
+ import { createStore, useStore } from "/assets/app-store.js";
11
+ import { Icon } from "/assets/ui-icons.js";
12
+
13
+ const FILE_URL = /^\/ui\/file\/[0-9a-f]{32}$/;
14
+ /** What is shown: { items: [{ src, name }], index, opener } or null. One per page; the chat that shows it owns it. */
15
+ export const lightboxStore = createStore({ open: null });
16
+ let seq = 0;
17
+
18
+ /**
19
+ * The images of a message, from its thumbnails' links; `index` the one clicked. Anything that is not a chat file URL
20
+ * is left out (and nothing opens if the clicked one is not).
21
+ */
22
+ export function openLightbox(links, clicked) {
23
+ const items = [];
24
+ let index = -1;
25
+ for (const a of links) {
26
+ const src = a.getAttribute("href") || "";
27
+ if (!FILE_URL.test(src)) continue;
28
+ if (a === clicked) index = items.length;
29
+ const img = a.querySelector("img");
30
+ items.push({ src, name: (img && img.getAttribute("alt")) || "" });
31
+ }
32
+ if (index < 0) return false;
33
+ lightboxStore.set({ open: { items, index, opener: clicked, seq: ++seq } });
34
+ return true;
35
+ }
36
+ /**
37
+ * Close it — the one way every close goes: ✕, the dark area, Esc, the dialog's own close (the browser's, a form with
38
+ * method=dialog), the chat leaving. The store is the truth; unmounting the dialog is the one cleanup (scroll unlocked,
39
+ * focus back on the thumbnail). `seq`: only that lightbox — a late close of one already replaced closes nothing.
40
+ */
41
+ export function closeLightbox(seq) {
42
+ const open = lightboxStore.get().open;
43
+ if (open && (seq === undefined || open.seq === seq)) lightboxStore.set({ open: null });
44
+ }
45
+
46
+ /** Rendered by the chat; gone with it (another instance, another panel). */
47
+ export function LightboxHost() {
48
+ const { open } = useStore(lightboxStore);
49
+ useEffect(() => () => closeLightbox(), []);
50
+ return open ? html`<${ImageLightbox} key=${open.seq} items=${open.items} index=${open.index} opener=${open.opener} onClose=${() => closeLightbox(open.seq)} />` : null;
51
+ }
52
+
53
+ function ImageLightbox({ items, index, opener, onClose }) {
54
+ const [at, setAt] = useState(index);
55
+ const ref = useRef(null);
56
+ const close = useRef(null);
57
+ const item = items[at] || items[0];
58
+ const many = items.length > 1;
59
+ const step = (d) => setAt((n) => (n + d + items.length) % items.length);
60
+ useLayoutEffect(() => {
61
+ const d = ref.current;
62
+ const root = typeof document !== "undefined" ? document.documentElement : null;
63
+ if (root) root.classList.add("lb-open"); // the chat behind it does not scroll
64
+ if (d && typeof d.showModal === "function" && !d.open) d.showModal();
65
+ if (close.current && typeof close.current.focus === "function") close.current.focus();
66
+ return () => {
67
+ if (root) root.classList.remove("lb-open");
68
+ if (d && d.open && typeof d.close === "function") d.close();
69
+ if (opener && typeof opener.focus === "function" && document.contains?.(opener)) opener.focus();
70
+ };
71
+ }, []);
72
+ useEffect(() => {
73
+ const d = ref.current;
74
+ if (!d) return undefined;
75
+ // Esc: the browser fires "cancel"; closing goes through the store so its state stays the truth.
76
+ const onCancel = (e) => { e.preventDefault(); onClose(); };
77
+ // The dialog closed by itself — the browser (a close request it did not ask about) or a form with method=dialog:
78
+ // the same close as every other, so the store and the cleanup follow (#1558 review).
79
+ const onNativeClose = () => onClose();
80
+ // The dark area (the dialog itself, or the stage around the image) closes it; the image and the controls do not.
81
+ const onClick = (e) => { if (e.target === d || (e.target && e.target.classList && e.target.classList.contains("lb-stage"))) onClose(); };
82
+ const onKey = (e) => {
83
+ if (!many) return;
84
+ if (e.key === "ArrowRight") { e.preventDefault(); step(1); } else if (e.key === "ArrowLeft") { e.preventDefault(); step(-1); }
85
+ };
86
+ d.addEventListener("cancel", onCancel);
87
+ d.addEventListener("close", onNativeClose);
88
+ d.addEventListener("click", onClick);
89
+ d.addEventListener("keydown", onKey);
90
+ return () => { d.removeEventListener("cancel", onCancel); d.removeEventListener("close", onNativeClose); d.removeEventListener("click", onClick); d.removeEventListener("keydown", onKey); };
91
+ }, [onClose, many, items.length]);
92
+ const label = item.name ? t("chat.lbLabel", item.name) : t("chat.lbLabelNoName");
93
+ return html`<dialog ref=${ref} class="lb" role="dialog" aria-modal="true" aria-label=${label}>
94
+ <div class="lb-bar">
95
+ <span class="lb-name" title=${item.name}>${item.name}</span>
96
+ ${many ? html`<span class="lb-count">${t("chat.lbCount", at + 1, items.length)}</span>` : null}
97
+ <a class="btn btn-sm lb-orig" href=${item.src} target="_blank" rel="noopener noreferrer"><${Icon} name="external" size=${14} /><span class="lbl">${t("chat.lbOpenOriginal")}</span></a>
98
+ <button type="button" class="icon-btn lb-close" ref=${close} aria-label=${t("chat.lbClose")} title=${t("chat.lbClose")} onClick=${onClose}><${Icon} name="close" /></button>
99
+ </div>
100
+ <div class="lb-stage"><img class="lb-img" src=${item.src} alt=${item.name} /></div>
101
+ ${many ? html`<button type="button" class="icon-btn lb-nav lb-prev" aria-label=${t("chat.lbPrev")} title=${t("chat.lbPrev")} onClick=${() => step(-1)}><${Icon} name="back" /></button>
102
+ <button type="button" class="icon-btn lb-nav lb-next" aria-label=${t("chat.lbNext")} title=${t("chat.lbNext")} onClick=${() => step(1)}><${Icon} name="forward" /></button>` : null}
103
+ </dialog>`;
104
+ }
@@ -11,10 +11,13 @@ import { t } from "/assets/app-i18n.js";
11
11
  import { appStore, useStore } from "/assets/app-store.js";
12
12
  import { useLease } from "/assets/app-ctx.js";
13
13
  import { PanelHeader, onPanelKey, setTitle, addFooterItem, statusClass, statusLabel, openDrawer, requestNewInstance } from "/assets/app-shell.js";
14
+ import { detailsPath } from "/assets/app-route.js";
15
+ import { InstanceSwitch } from "/assets/instance-switch.js";
16
+ import { HeaderTools } from "/assets/header-tools.js";
14
17
  import { navigate } from "/assets/app-nav.js";
15
18
  import { Menu } from "/assets/ui-menu.js";
16
19
  import { Dialog } from "/assets/ui-dialog.js";
17
- import { Empty, ErrorState, Skeleton } from "/assets/ui-states.js";
20
+ import { Empty, Skeleton } from "/assets/ui-states.js";
18
21
  import { Icon } from "/assets/ui-icons.js";
19
22
  import { toast } from "/assets/ui-toast.js";
20
23
  import { confirmDialog } from "/assets/ui-confirm.js";
@@ -26,6 +29,7 @@ import { PreviewPanel, openPanel, panelStore, shownKey } from "./preview-panel.j
26
29
  import { needsArgument, paletteFor, parseCommandLine } from "./chat-commands.js";
27
30
  import { installTour, refreshTourSpot, startTour } from "./chat-tour.js";
28
31
  import { FirstRunCard } from "./first-run.js";
32
+ import { LightboxHost, openLightbox } from "./image-lightbox.js";
29
33
 
30
34
  const R = () => globalThis.AgendChatRender;
31
35
  const P = () => globalThis.AgendPreview;
@@ -140,7 +144,7 @@ export function ChatPanel({ route, navKey }) {
140
144
  }
141
145
  if (!app.ready) return html`<div class="panel p-chat"><${PanelHeader} title=${name} /><div class="panel-body"><${Skeleton} lines=${5} /></div></div>`;
142
146
  if (!inst) return html`<${NotFound} name=${name} />`;
143
- return html`<${ChatView} key=${name} name=${name} inst=${inst} lease=${lease} exec=${app.exec[name]}
147
+ return html`<${ChatView} key=${name} name=${name} inst=${inst} lease=${lease} navKey=${navKey} exec=${app.exec[name]}
144
148
  awaiting=${Object.prototype.hasOwnProperty.call(app.awaiting, name) ? app.awaiting[name] : null} />`;
145
149
  }
146
150
 
@@ -152,8 +156,8 @@ function NotFound({ name }) {
152
156
  action=${html`<button type="button" class="btn only-narrow" onClick=${openDrawer}>${t("chat.backToList")}</button>`} /></div></div>`;
153
157
  }
154
158
 
155
- function ChatView({ name, inst, lease, exec, awaiting }) {
156
- const [dialog, setDialog] = useState(null); // "details" | "delete" | null
159
+ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
160
+ const [dialog, setDialog] = useState(null); // "delete" | null
157
161
  const [wrap, setWrap] = useState(codeWrap);
158
162
  const view = useRef(null), split = useRef(null), thread = useRef(null);
159
163
  useEffect(() => { wrapListeners.add(setWrap); return () => wrapListeners.delete(setWrap); }, []);
@@ -191,7 +195,8 @@ function ChatView({ name, inst, lease, exec, awaiting }) {
191
195
  else toast(t(verb === "start" ? "chat.started" : verb === "stop" ? "chat.stoppedInst" : "chat.restarted", name));
192
196
  };
193
197
  const items = [
194
- { key: "details", label: t("chat.details"), icon: "info", onSelect: () => setDialog("details") },
198
+ // #1523 N2: Details is a page now (the Fleet side of this instance), not a dialog.
199
+ { key: "details", label: t("chat.details"), icon: "info", onSelect: () => navigate(detailsPath(name)) },
195
200
  running ? null : { key: "start", label: t("chat.start"), icon: "play", onSelect: () => action("start") },
196
201
  running ? { key: "restart", label: t("chat.restart"), icon: "restart", onSelect: () => action("restart") } : null,
197
202
  running ? { key: "stop", label: t("chat.stopInstance"), icon: "stop", onSelect: () => action("stop") } : null,
@@ -204,7 +209,7 @@ function ChatView({ name, inst, lease, exec, awaiting }) {
204
209
  ${inst.model ? html`<button type="button" class="hd-chip" title=${t("chat.chipModel", inst.model)} aria-label=${t("chat.chipModel", inst.model)} onClick=${() => pick("model")}>${inst.model}</button>` : null}
205
210
  ${inst.effort ? html`<button type="button" class="hd-chip" title=${t("chat.chipEffort", inst.effort)} aria-label=${t("chat.chipEffort", inst.effort)} onClick=${() => pick("effort")}>${inst.effort}</button>` : null}`;
206
211
  return html`<div class=${`panel p-chat${wrap ? " wrap-code" : ""}`} ref=${view}>
207
- <${PanelHeader} title=${name} sub=${sub}><${Menu} items=${items} label=${t("app.more")} /></${PanelHeader}>
212
+ <${PanelHeader} title=${name} sub=${sub} nav=${html`<${InstanceSwitch} name=${name} current="chat" />`}><${HeaderTools} navKey=${navKey} /><${Menu} items=${items} label=${t("app.more")} /></${PanelHeader}>
208
213
  <div class="chat-split" ref=${split}>
209
214
  <div class="chat-main">
210
215
  <${Thread} name=${name} th=${thread} />
@@ -213,8 +218,8 @@ function ChatView({ name, inst, lease, exec, awaiting }) {
213
218
  <${PreviewPanel} name=${name} split=${split} msgs=${() => store.state.msgs[name] || []} subscribe=${(fn) => store.subscribe(fn)}
214
219
  keyOf=${msgKey} reveal=${(k) => thread.current && thread.current.reveal(k)} download=${downloadHtml} allow=${() => setPreviewOptIn(true)} />
215
220
  </div>
221
+ <${LightboxHost} />
216
222
  <div class="drop-overlay" aria-hidden="true"><div class="drop-card"><${Icon} name="attach" size=${32} /><span>${t("chat.dropHere")}</span></div></div>
217
- ${dialog === "details" ? html`<${DetailsDialog} name=${name} onClose=${() => setDialog(null)} />` : null}
218
223
  ${dialog === "delete" ? html`<${DeleteDialog} name=${name} onClose=${() => setDialog(null)} />` : null}
219
224
  </div>`;
220
225
  }
@@ -231,6 +236,7 @@ function Thread({ name, th }) {
231
236
  clickReplyButton: (id, index) => store.clickReplyButton(name, id, index), // #1266
232
237
  replyButtonBusy: (id) => store.state.rbBusy.has(id),
233
238
  panel: { shown: shownKey, open: (spec, o) => openPanel(spec, o) }, // #1481
239
+ openImage: openLightbox, // an image, full size, in the page
234
240
  });
235
241
  th.current = thread;
236
242
  thread.render(store.state.msgs[name] || [], { restore: store.state.scrollMemo[name] ?? null });
@@ -430,51 +436,6 @@ function FileChip({ f, i, name }) {
430
436
 
431
437
  // ── Dialogs ──
432
438
 
433
- function DetailsDialog({ name, onClose }) {
434
- const lease = useLease(`details:${name}`);
435
- const [d, setD] = useState(null);
436
- const [failed, setFailed] = useState(false);
437
- const body = useRef(null);
438
- useEffect(() => {
439
- (async () => {
440
- try {
441
- const r = await lease.fetch(`/ui/instance/${encodeURIComponent(name)}`, { headers: { "Content-Type": "application/json" } });
442
- if (!lease.current()) return;
443
- const j = await r.json();
444
- if (!lease.current()) return;
445
- setD(j);
446
- } catch { if (lease.current()) setFailed(true); }
447
- })();
448
- }, [lease]);
449
- // Bars are sized after they are drawn, through the CSSOM (never a style attribute).
450
- useLayoutEffect(() => { if (body.current) for (const f of body.current.querySelectorAll(".progress-fill[data-pct]")) f.style.width = `${f.dataset.pct}%`; });
451
- const src = (s) => (s === "instance" ? ` ${t("chat.configured")}` : s === "fleet-default" ? ` ${t("chat.fleetDefault")}` : "");
452
- const bar = (pct, level) => html`<span class="progress-bar"><span class=${`progress-fill${level ? ` ${level}` : ""}`} data-pct=${Math.max(0, Math.min(Number(pct) || 0, 100))}></span></span>`;
453
- const row = (k, v) => html`<div class="kv"><span class="k">${k}</span><span class="v">${v}</span></div>`;
454
- let content;
455
- if (failed) content = html`<${ErrorState} message=${t("chat.loadFailed")} />`;
456
- else if (!d) content = html`<${Skeleton} lines=${6} />`;
457
- else {
458
- const sl = d.statusline || {}, cost = sl.cost?.total_cost_usd ?? 0, ctx = d.context_pct;
459
- const rl5 = sl.rate_limits?.five_hour?.used_percentage ?? 0, rl7 = sl.rate_limits?.seven_day?.used_percentage ?? 0;
460
- content = html`<section class="card"><h3>${t("chat.dInstance")}</h3>
461
- ${row(t("chat.dName"), d.name)}${row(t("chat.dStatus"), d.status)}${row(t("chat.dDisplay"), d.display_name || "--")}
462
- ${row(t("chat.dDescription"), d.description || "--")}${row(t("chat.dDirectory"), html`<span class="mono">${d.working_directory}</span>`)}</section>
463
- <section class="card"><h3>${t("chat.dRuntime")}</h3>
464
- ${row(t("chat.dBackend"), d.backend || "--")}
465
- ${row(t("chat.dModel"), `${d.model || sl.model?.display_name || "--"}${d.model_source === "live" ? "" : src(d.model_source)}`)}
466
- ${d.effort ? row(t("chat.dEffort"), `${d.effort}${src(d.effort_source)}`) : null}
467
- ${row(t("chat.dCost"), `$${cost.toFixed(2)} ${t("chat.dCostNote")}`)}
468
- ${row(t("chat.dContext"), html`<span class="pct">${ctx != null ? `${Math.round(ctx)}%` : "--"}</span>${ctx != null ? bar(ctx, ctx > 80 ? "warn" : "") : null}`)}
469
- ${row(t("chat.dRate5"), html`<span class="pct">${Math.round(rl5)}%</span>${bar(rl5, rl5 > 90 ? "error" : rl5 > 70 ? "warn" : "")}`)}
470
- ${row(t("chat.dRate7"), html`<span class="pct">${Math.round(rl7)}%</span>${bar(rl7, rl7 > 90 ? "error" : rl7 > 70 ? "warn" : "")}`)}</section>
471
- ${d.recent_activity?.length ? html`<section class="card"><h3>${t("chat.dActivity")}</h3>${d.recent_activity.map((a, i) => html`<div key=${i} class="activity">
472
- <span class="mono muted">${a.timestamp ? new Date(`${a.timestamp.replace(" ", "T")}Z`).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""}</span>
473
- <span class="ev">${a.event}</span> ${a.summary || ""}</div>`)}</section>` : null}`;
474
- }
475
- return html`<${Dialog} title=${t("chat.details")} onClose=${onClose} wide=${true}><div ref=${body}>${content}</div></${Dialog}>`;
476
- }
477
-
478
439
  function DeleteDialog({ name, onClose }) {
479
440
  // The dialog's own lease: it ends when the dialog goes (closed, or the chat left for another page). The delete
480
441
  // may still finish on the server, and says so, but a dialog that is gone never closes or navigates the page the
@@ -0,0 +1,146 @@
1
+ // #1523 N2 (§3.3, Q1 = B): Details — "the Fleet side of one instance", at /ui/fleet/agent/<name>. Behind the session
2
+ // gate (/ui/js/). What it holds:
3
+ // - runtime and recent activity (what Chat's details dialog showed, now a page);
4
+ // - a read-only config summary — directory, binding, tags, description — with "Edit in Settings" (that agent's
5
+ // Settings dialog: Settings' own staged Apply and confirmations; nothing is edited here);
6
+ // - the instance's actions (⋯), carried over with their calls and confirmations, none new: start / restart / stop (Chat's
7
+ // ⋯), pause / wake (Settings' agent row). Deletion stays where it already lives (Chat's ⋯, Settings).
8
+ // One read of /ui/instance/<name> when it opens (#1374: nothing recurring); the live status is the app's.
9
+ import { html, useEffect, useLayoutEffect, useRef, useState } from "/assets/app-html.js";
10
+ import { t } from "/assets/app-i18n.js";
11
+ import { appStore, useStore } from "/assets/app-store.js";
12
+ import { useLease } from "/assets/app-ctx.js";
13
+ import { PanelHeader, setTitle, statusClass, statusLabel, openDrawer } from "/assets/app-shell.js";
14
+ import { navigate } from "/assets/app-nav.js";
15
+ import { settingsPath } from "/assets/app-route.js";
16
+ import { InstanceSwitch } from "/assets/instance-switch.js";
17
+ import { HeaderTools } from "/assets/header-tools.js";
18
+ import { Menu } from "/assets/ui-menu.js";
19
+ import { Empty, ErrorState, Skeleton } from "/assets/ui-states.js";
20
+ import { toast } from "/assets/ui-toast.js";
21
+ import "./chat-strings.js";
22
+ import { api } from "./settings-confirm.js";
23
+ import { requestAgentSettings } from "./settings-request.js";
24
+
25
+ export function DetailsPanel({ route, navKey }) {
26
+ const app = useStore(appStore);
27
+ const name = route.instance;
28
+ const inst = app.instances.find((i) => i.name === name) || null;
29
+ useEffect(() => { setTitle(`${name} · ${t("app.details")}`); }, [name, navKey]);
30
+ if (!app.ready) return html`<div class="panel p-details"><${PanelHeader} title=${name} /><div class="panel-body"><${Skeleton} lines=${6} /></div></div>`;
31
+ if (!inst) {
32
+ return html`<div class="panel p-details"><${PanelHeader} title=${name} />
33
+ <div class="panel-body center"><${Empty} icon="alert" title=${t("chat.notFound", name)} hint=${t("chat.notFoundHint")}
34
+ action=${html`<button type="button" class="btn only-narrow" onClick=${openDrawer}>${t("chat.backToList")}</button>`} /></div></div>`;
35
+ }
36
+ return html`<${DetailsView} key=${name} name=${name} inst=${inst} navKey=${navKey} exec=${app.exec[name]}
37
+ awaiting=${Object.prototype.hasOwnProperty.call(app.awaiting, name) ? app.awaiting[name] : null} />`;
38
+ }
39
+
40
+ function DetailsView({ name, inst, navKey, exec, awaiting }) {
41
+ const lease = useLease(`details:${name}`);
42
+ const [d, setD] = useState(null);
43
+ const [failed, setFailed] = useState(false);
44
+ const [busy, setBusy] = useState("");
45
+ const body = useRef(null);
46
+ // Each read is numbered: only the latest one (on this page) may set what is shown — a Retry still on its way never
47
+ // overwrites a newer answer, failed or not (#1561 review).
48
+ const reads = useRef(0);
49
+ const load = async () => {
50
+ const mine = ++reads.current;
51
+ const current = () => lease.current() && reads.current === mine;
52
+ try {
53
+ const r = await lease.fetch(`/ui/instance/${encodeURIComponent(name)}`, { headers: { "Content-Type": "application/json" } });
54
+ if (!current()) return;
55
+ const j = await r.json();
56
+ if (!current()) return;
57
+ if (!r.ok) { setFailed(true); return; }
58
+ setFailed(false); setD(j);
59
+ } catch { if (current()) setFailed(true); }
60
+ };
61
+ useEffect(() => { load(); }, [lease]);
62
+ // Bars are sized after they are drawn, through the CSSOM (never a style attribute, #1300).
63
+ useLayoutEffect(() => { if (body.current) for (const f of body.current.querySelectorAll(".progress-fill[data-pct]")) f.style.width = `${f.dataset.pct}%`; });
64
+
65
+ // ── Actions: the existing calls, one at a time ──
66
+ const act = async (key, work) => {
67
+ if (busy) return;
68
+ setBusy(key);
69
+ try { await work(); } finally { if (lease.current()) setBusy(""); }
70
+ };
71
+ const lifecycle = (verb) => act(verb, async () => {
72
+ let r;
73
+ try { const res = await fetch(`/ui/${verb}/${encodeURIComponent(name)}`, { method: "POST", headers: { "Content-Type": "application/json" } }); r = await res.json(); }
74
+ catch (err) { r = { error: err && err.message ? err.message : t("chat.disconnected") }; }
75
+ if (r && r.error) toast(r.error, false);
76
+ else toast(t(verb === "start" ? "chat.started" : verb === "stop" ? "chat.stoppedInst" : "chat.restarted", name));
77
+ });
78
+ const pauseWake = (verb) => act(verb, async () => {
79
+ const res = await api(`/api/settings/instances/${encodeURIComponent(name)}/${verb}`, { method: "POST" }).catch(() => ({ ok: false, body: {} }));
80
+ if (!res.ok) toast((res.body && res.body.error) || t("chat.dActionFailed"), false);
81
+ else toast(t(verb === "pause" ? "chat.dPaused" : "chat.dWoken", name));
82
+ });
83
+ // A ClassicBot room (classicBot.yaml) keeps Classic's split: its own Settings dialog, and only the actions Settings'
84
+ // Classic rows have (pause / wake) — never the fleet agent's (#1561 review).
85
+ const classic = !!(d && d.kind === "classic");
86
+ const editInSettings = () => { requestAgentSettings(name, classic ? "classic" : "agent"); navigate(settingsPath(classic ? "classic" : undefined)); };
87
+ const st = inst.status;
88
+ // Until its read is in, the page does not know which kind it is: no actions yet (only Edit is offered once known).
89
+ const items = !d ? [] : classic ? [
90
+ { key: "edit", label: t("chat.dEditInSettings"), icon: "settings", onSelect: editInSettings },
91
+ st === "running" ? { key: "pause", label: t("chat.dPause"), icon: "pause", disabled: !!busy, onSelect: () => pauseWake("pause") } : null,
92
+ st === "paused" ? { key: "wake", label: t("chat.dWake"), icon: "play", disabled: !!busy, onSelect: () => pauseWake("wake") } : null,
93
+ ] : [
94
+ { key: "edit", label: t("chat.dEditInSettings"), icon: "settings", onSelect: editInSettings },
95
+ st === "running" ? null : st === "paused" ? null : { key: "start", label: t("chat.start"), icon: "play", disabled: !!busy, onSelect: () => lifecycle("start") },
96
+ st === "running" ? { key: "restart", label: t("chat.restart"), icon: "restart", disabled: !!busy, onSelect: () => lifecycle("restart") } : null,
97
+ st === "running" ? { key: "stop", label: t("chat.stopInstance"), icon: "stop", disabled: !!busy, onSelect: () => lifecycle("stop") } : null,
98
+ st === "running" ? { key: "pause", label: t("chat.dPause"), icon: "pause", disabled: !!busy, onSelect: () => pauseWake("pause") } : null,
99
+ st === "paused" ? { key: "wake", label: t("chat.dWake"), icon: "play", disabled: !!busy, onSelect: () => pauseWake("wake") } : null,
100
+ ];
101
+
102
+ const cls = statusClass(inst, exec, awaiting);
103
+ const sub = html`<span class="status"><span class=${`dot ${cls}`} aria-hidden="true"></span>${statusLabel(inst, exec, awaiting)}</span>`;
104
+ const src = (s) => (s === "instance" ? ` ${t("chat.configured")}` : s === "fleet-default" ? ` ${t("chat.fleetDefault")}` : "");
105
+ const bar = (pct, level) => html`<span class="progress-bar"><span class=${`progress-fill${level ? ` ${level}` : ""}`} data-pct=${Math.max(0, Math.min(Number(pct) || 0, 100))}></span></span>`;
106
+ const row = (k, v) => html`<div class="kv"><span class="k">${k}</span><span class="v">${v}</span></div>`;
107
+ let content;
108
+ if (failed) content = html`<${ErrorState} message=${t("chat.loadFailed")} onRetry=${load} />`;
109
+ else if (!d) content = html`<${Skeleton} lines=${6} />`;
110
+ else {
111
+ const sl = d.statusline || {}, cost = sl.cost?.total_cost_usd ?? 0, ctx = d.context_pct;
112
+ const rl5 = sl.rate_limits?.five_hour?.used_percentage ?? 0, rl7 = sl.rate_limits?.seven_day?.used_percentage ?? 0;
113
+ // Where it is bound, as the fleet resolves it: no channel_id is the first connection (said so); "web only" only
114
+ // when the fleet has no connection at all (#1561 review).
115
+ const b = d.binding || {};
116
+ const conn = b.channel_id ? (b.implicit ? t("chat.dBindingDefault", b.channel_id) : b.channel_id) : null;
117
+ const binding = d.room ? t("chat.dBindingRoom", d.room.name, d.room.adapter_id || t("chat.dPrimary"))
118
+ : !conn ? t("chat.dBindingNone")
119
+ : b.general_topic ? t("chat.dBindingGeneral", conn)
120
+ : b.topic_id ? t("chat.dBindingTopic", conn, b.topic_id)
121
+ : t("chat.dBindingNoTopic", conn);
122
+ content = html`<section class="card d-config"><h3>${t("chat.dConfig")}</h3>
123
+ ${row(t("chat.dDisplay"), d.display_name || "--")}
124
+ ${row(t("chat.dDescription"), d.description || "--")}
125
+ ${d.working_directory ? row(t("chat.dDirectory"), html`<span class="mono">${d.working_directory}</span>`) : null}
126
+ ${row(t("chat.dBinding"), binding)}
127
+ ${row(t("chat.dTags"), Array.isArray(d.tags) && d.tags.length ? html`<span class="d-tags">${d.tags.map((x) => html`<span key=${x} class="tag">${x}</span>`)}</span>` : "--")}
128
+ <div class="d-edit"><span class="note">${t("chat.dReadOnly")}</span>
129
+ <button type="button" class="btn btn-sm d-edit-settings" onClick=${editInSettings}>${t("chat.dEditInSettings")}</button></div></section>
130
+ <section class="card"><h3>${t("chat.dRuntime")}</h3>
131
+ ${row(t("chat.dBackend"), d.backend || "--")}
132
+ ${row(t("chat.dModel"), `${d.model || sl.model?.display_name || "--"}${d.model_source === "live" ? "" : src(d.model_source)}`)}
133
+ ${d.effort ? row(t("chat.dEffort"), `${d.effort}${src(d.effort_source)}`) : null}
134
+ ${row(t("chat.dCost"), `$${cost.toFixed(2)} ${t("chat.dCostNote")}`)}
135
+ ${row(t("chat.dContext"), html`<span class="pct">${ctx != null ? `${Math.round(ctx)}%` : "--"}</span>${ctx != null ? bar(ctx, ctx > 80 ? "warn" : "") : null}`)}
136
+ ${row(t("chat.dRate5"), html`<span class="pct">${Math.round(rl5)}%</span>${bar(rl5, rl5 > 90 ? "error" : rl5 > 70 ? "warn" : "")}`)}
137
+ ${row(t("chat.dRate7"), html`<span class="pct">${Math.round(rl7)}%</span>${bar(rl7, rl7 > 90 ? "error" : rl7 > 70 ? "warn" : "")}`)}</section>
138
+ <section class="card"><h3>${t("chat.dActivity")}</h3>${d.recent_activity?.length ? d.recent_activity.map((a, i) => html`<div key=${i} class="activity">
139
+ <span class="mono muted">${a.timestamp ? new Date(`${a.timestamp.replace(" ", "T")}Z`).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""}</span>
140
+ <span class="ev">${a.event}</span> ${a.summary || ""}</div>`) : html`<p class="note">${t("chat.dNoActivity")}</p>`}</section>`;
141
+ }
142
+ return html`<div class="panel p-details">
143
+ <${PanelHeader} title=${name} sub=${sub} nav=${html`<${InstanceSwitch} name=${name} current="details" />`}><${HeaderTools} navKey=${navKey} /><${Menu} items=${items} label=${t("app.more")} /></${PanelHeader}>
144
+ <div class="panel-body"><div class="col" ref=${body}>${busy ? html`<p class="note" role="status">${t("chat.dWorking")}</p>` : null}${content}</div></div>
145
+ </div>`;
146
+ }
@@ -9,6 +9,7 @@ import { t, register } from "/assets/app-i18n.js";
9
9
  import { appStore, useStore } from "/assets/app-store.js";
10
10
  import { useLease } from "/assets/app-ctx.js";
11
11
  import { PanelHeader, setTitle } from "/assets/app-shell.js";
12
+ import { HeaderTools } from "/assets/header-tools.js";
12
13
  import { renavigate } from "/assets/app-nav.js";
13
14
  import { chatPath, fleetPath, FLEET_TABS } from "/assets/app-route.js";
14
15
  import { Dialog } from "/assets/ui-dialog.js";
@@ -104,7 +105,7 @@ export function FleetPanel({ route, navKey }) {
104
105
  useEffect(() => { setTitle(`${t("fleet.title")} · ${t(`fleet.${tab}`)}`); }, [tab, navKey]);
105
106
  const Body = { tasks: Tasks, schedules: Schedules, teams: Teams, org: Org, cache: Cache, config: Config }[tab] || Tasks;
106
107
  return html`<div class="panel p-fleet">
107
- <${PanelHeader} title=${t("fleet.title")} />
108
+ <${PanelHeader} title=${t("fleet.title")}><${HeaderTools} navKey=${navKey} /></${PanelHeader}>
108
109
  <nav class="seg" aria-label=${t("fleet.title")}>${FLEET_TABS.map(k => html`<a key=${k} href=${fleetPath(k)} class=${`seg-item${k === tab ? " active" : ""}`}
109
110
  aria-current=${k === tab ? "page" : undefined}><${Icon} name=${ICONS[k]} size=${16} /><span>${t(`fleet.${k}`)}</span></a>`)}</nav>
110
111
  <div class="panel-body"><div class=${`col${tab === "org" || tab === "cache" ? " col-wide" : ""}`}><${Body} lease=${lease} /></div></div>
@@ -36,6 +36,7 @@ import {
36
36
  import { SetupWizard } from "./settings-wizard.js";
37
37
  import { CreateInstanceDialog } from "./panel-fleet.js";
38
38
  import { FirstRunCard, takeWizardRequest } from "./first-run.js";
39
+ import { takeAgentRequest } from "./settings-request.js";
39
40
 
40
41
  const tn = (k, ...v) => t(`settings.${k}`, ...v);
41
42
  const ICONS = { agents: "bot", bots: "plug", classic: "room", general: "sliders", advanced: "code" };
@@ -200,6 +201,23 @@ export function SettingsPanel({ route, navKey }) {
200
201
  const ctx = data && !data.error ? makeCtx(data, setData, stage, unstage, reload, reloadLive, () => mount.current()) : null;
201
202
  // #1519 P7: "Connect a chat app" on the first-run card elsewhere came here for the wizard: it opens once the data is in.
202
203
  useEffect(() => { if (ctx && takeWizardRequest()) setDialog({ kind: "wizard", key: navKey }); }, [!!ctx, navKey]);
204
+ // #1523 N2: "Edit in Settings" on an instance's Details: that agent's dialog, once the data is in — only an agent this
205
+ // configuration has (own key), with Settings' own staged Apply and confirmations.
206
+ useEffect(() => {
207
+ if (!ctx) return;
208
+ const want = takeAgentRequest();
209
+ if (!want) return;
210
+ const own = (o, k) => !!o && Object.prototype.hasOwnProperty.call(o, k);
211
+ if (want.kind === "classic") {
212
+ // A ClassicBot room: its own dialog (ClassicDialog), found by its instance among this config's rooms.
213
+ const rooms = (ctx.classic && ctx.classic.channels) || {};
214
+ const key = Object.keys(rooms).find((k) => own(rooms, k) && rooms[k] && rooms[k].instanceName === want.name);
215
+ if (key) setDialog({ kind: "classic", room: { key, ...rooms[key] }, key: navKey });
216
+ return;
217
+ }
218
+ const insts = (ctx.fleet && ctx.fleet.instances) || {};
219
+ if (own(insts, want.name)) setDialog({ kind: "agent", name: want.name, inst: insts[want.name], key: navKey });
220
+ }, [!!ctx, navKey]);
203
221
  const sectionBody = !data ? html`<${Skeleton} lines=${6} />`
204
222
  : data.error ? html`<${ErrorState} message=${tn("configLoadFailed")} onRetry=${reload} />`
205
223
  : section === "agents" ? html`<${Agents} ctx=${ctx} search=${search} openDialog=${(d) => setDialog({ ...d, key: navKey })} />`
@@ -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). */