@nordbyte/nordrelay 0.8.1 → 0.8.3

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 (179) hide show
  1. package/.env.example +9 -0
  2. package/README.md +84 -1205
  3. package/dist/{access-control.js → access/access-control.js} +1 -1
  4. package/dist/{audit-log.js → access/audit-log.js} +32 -15
  5. package/dist/{session-locks.js → access/session-locks.js} +1 -1
  6. package/dist/{user-management.js → access/user-management.js} +1 -1
  7. package/dist/{claude-code-cli.js → agents/claude-code/claude-code-cli.js} +2 -2
  8. package/dist/{claude-code-session.js → agents/claude-code/claude-code-session.js} +1 -1
  9. package/dist/{codex-cli.js → agents/codex/codex-cli.js} +14 -5
  10. package/dist/{codex-session.js → agents/codex/codex-session.js} +2 -4
  11. package/dist/{hermes-cli.js → agents/hermes/hermes-cli.js} +2 -2
  12. package/dist/{hermes-launch.js → agents/hermes/hermes-launch.js} +1 -1
  13. package/dist/{hermes-session.js → agents/hermes/hermes-session.js} +1 -1
  14. package/dist/{openclaw-cli.js → agents/openclaw/openclaw-cli.js} +2 -2
  15. package/dist/{openclaw-launch.js → agents/openclaw/openclaw-launch.js} +1 -1
  16. package/dist/{openclaw-session.js → agents/openclaw/openclaw-session.js} +1 -1
  17. package/dist/{pi-cli.js → agents/pi/pi-cli.js} +2 -2
  18. package/dist/{pi-launch.js → agents/pi/pi-launch.js} +1 -1
  19. package/dist/{pi-session.js → agents/pi/pi-session.js} +1 -1
  20. package/dist/{adapter-conformance.js → agents/shared/adapter-conformance.js} +2 -2
  21. package/dist/{agent-activity.js → agents/shared/agent-activity.js} +5 -5
  22. package/dist/agents/shared/agent-auth-commands.js +30 -0
  23. package/dist/{agent-factory.js → agents/shared/agent-factory.js} +5 -5
  24. package/dist/{agent-feature-matrix.js → agents/shared/agent-feature-matrix.js} +2 -2
  25. package/dist/{agent-updates.js → agents/shared/agent-updates.js} +7 -7
  26. package/dist/{discord-artifacts.js → channels/discord/discord-artifacts.js} +4 -4
  27. package/dist/{discord-bot.js → channels/discord/discord-bot.js} +176 -451
  28. package/dist/{discord-channel-runtime.js → channels/discord/discord-channel-runtime.js} +2 -2
  29. package/dist/{discord-command-surface.js → channels/discord/discord-command-surface.js} +3 -3
  30. package/dist/{bot-rendering.js → channels/shared/bot-rendering.js} +6 -6
  31. package/dist/{channel-actions.js → channels/shared/channel-actions.js} +4 -4
  32. package/dist/channels/shared/channel-bridge-controller.js +69 -0
  33. package/dist/channels/shared/channel-cli-artifacts.js +51 -0
  34. package/dist/{channel-command-service.js → channels/shared/channel-command-service.js} +51 -28
  35. package/dist/channels/shared/channel-external-mirror-controller.js +193 -0
  36. package/dist/channels/shared/channel-external-monitor.js +52 -0
  37. package/dist/{channel-mirror-registry.js → channels/shared/channel-mirror-registry.js} +14 -6
  38. package/dist/{channel-peer-prompt.js → channels/shared/channel-peer-prompt.js} +3 -3
  39. package/dist/channels/shared/channel-prompt-queue.js +37 -0
  40. package/dist/{channel-turn-service.js → channels/shared/channel-turn-service.js} +25 -11
  41. package/dist/{context-key.js → channels/shared/context-key.js} +1 -1
  42. package/dist/{session-format.js → channels/shared/session-format.js} +2 -2
  43. package/dist/{slack-artifacts.js → channels/slack/slack-artifacts.js} +4 -4
  44. package/dist/{slack-bot.js → channels/slack/slack-bot.js} +171 -309
  45. package/dist/{slack-channel-runtime.js → channels/slack/slack-channel-runtime.js} +2 -2
  46. package/dist/{slack-command-surface.js → channels/slack/slack-command-surface.js} +2 -2
  47. package/dist/{slack-diagnostics.js → channels/slack/slack-diagnostics.js} +2 -2
  48. package/dist/{bot-ui.js → channels/telegram/bot-ui.js} +1 -1
  49. package/dist/{bot.js → channels/telegram/bot.js} +195 -430
  50. package/dist/{telegram-access-commands.js → channels/telegram/telegram-access-commands.js} +3 -3
  51. package/dist/{telegram-access-middleware.js → channels/telegram/telegram-access-middleware.js} +4 -4
  52. package/dist/{telegram-agent-commands.js → channels/telegram/telegram-agent-commands.js} +9 -9
  53. package/dist/{telegram-artifact-commands.js → channels/telegram/telegram-artifact-commands.js} +4 -4
  54. package/dist/{telegram-channel-runtime.js → channels/telegram/telegram-channel-runtime.js} +2 -2
  55. package/dist/{telegram-command-menu.js → channels/telegram/telegram-command-menu.js} +1 -1
  56. package/dist/{telegram-diagnostics-command.js → channels/telegram/telegram-diagnostics-command.js} +7 -7
  57. package/dist/{telegram-general-commands.js → channels/telegram/telegram-general-commands.js} +4 -4
  58. package/dist/{telegram-operational-commands.js → channels/telegram/telegram-operational-commands.js} +5 -5
  59. package/dist/{telegram-output.js → channels/telegram/telegram-output.js} +2 -2
  60. package/dist/{telegram-preference-commands.js → channels/telegram/telegram-preference-commands.js} +3 -3
  61. package/dist/{telegram-queue-commands.js → channels/telegram/telegram-queue-commands.js} +6 -6
  62. package/dist/{telegram-support-command.js → channels/telegram/telegram-support-command.js} +4 -4
  63. package/dist/{telegram-update-commands.js → channels/telegram/telegram-update-commands.js} +5 -5
  64. package/dist/{config-metadata.js → core/config-metadata.js} +8 -0
  65. package/dist/{config.js → core/config.js} +11 -3
  66. package/dist/core/pagination.js +22 -0
  67. package/dist/index.js +27 -23
  68. package/dist/peers/peer-discovery-jobs.js +206 -0
  69. package/dist/peers/peer-discovery.js +223 -0
  70. package/dist/peers/peer-health-monitor.js +49 -0
  71. package/dist/{peer-identity.js → peers/peer-identity.js} +50 -1
  72. package/dist/{peer-runtime-service.js → peers/peer-runtime-service.js} +29 -7
  73. package/dist/{peer-server.js → peers/peer-server.js} +3 -2
  74. package/dist/{peer-store.js → peers/peer-store.js} +96 -9
  75. package/dist/{peer-types.js → peers/peer-types.js} +28 -0
  76. package/dist/peers/peer-web-proxy-contract.js +129 -0
  77. package/dist/{metrics.js → runtime/metrics.js} +5 -3
  78. package/dist/{relay-artifact-service.js → runtime/relay-artifact-service.js} +1 -1
  79. package/dist/runtime/relay-auth-service.js +63 -0
  80. package/dist/runtime/relay-dashboard-service.js +139 -0
  81. package/dist/{relay-external-activity-monitor.js → runtime/relay-external-activity-monitor.js} +155 -53
  82. package/dist/{relay-queue-service.js → runtime/relay-queue-service.js} +1 -0
  83. package/dist/runtime/relay-runtime-active-sessions.js +387 -0
  84. package/dist/runtime/relay-runtime-dashboard.js +204 -0
  85. package/dist/{relay-runtime-helpers.js → runtime/relay-runtime-helpers.js} +3 -0
  86. package/dist/runtime/relay-runtime-prompt-queue-artifacts.js +311 -0
  87. package/dist/runtime/relay-runtime-sessions.js +631 -0
  88. package/dist/runtime/relay-runtime-trace.js +92 -0
  89. package/dist/runtime/relay-runtime-types.js +1 -0
  90. package/dist/runtime/relay-runtime-updates-jobs.js +366 -0
  91. package/dist/runtime/relay-runtime.js +461 -0
  92. package/dist/runtime/runtime-cache.js +117 -0
  93. package/dist/{prompt-store.js → state/prompt-store.js} +13 -1
  94. package/dist/{session-registry.js → state/session-registry.js} +3 -3
  95. package/dist/{operations.js → support/operations.js} +7 -7
  96. package/dist/{support-bundle.js → support/support-bundle.js} +1 -1
  97. package/dist/{web-api-contract.js → web/web-api-contract.js} +19 -3
  98. package/dist/web/web-api-types.js +1 -0
  99. package/dist/{web-dashboard-access-routes.js → web/web-dashboard-access-routes.js} +17 -14
  100. package/dist/{web-dashboard-artifact-routes.js → web/web-dashboard-artifact-routes.js} +6 -2
  101. package/dist/{web-dashboard-assets.js → web/web-dashboard-assets.js} +25 -2
  102. package/dist/{web-dashboard-http.js → web/web-dashboard-http.js} +41 -5
  103. package/dist/{web-dashboard-pages.js → web/web-dashboard-pages.js} +95 -30
  104. package/dist/{web-dashboard-peer-routes.js → web/web-dashboard-peer-routes.js} +121 -7
  105. package/dist/{web-dashboard-runtime-routes.js → web/web-dashboard-runtime-routes.js} +8 -1
  106. package/dist/web/web-dashboard-security.js +14 -0
  107. package/dist/{web-dashboard-session-routes.js → web/web-dashboard-session-routes.js} +29 -13
  108. package/dist/web/web-dashboard-ui.js +56 -0
  109. package/dist/{web-dashboard.js → web/web-dashboard.js} +132 -48
  110. package/dist/web/web-performance.js +62 -0
  111. package/dist/web/web-rate-limit.js +19 -0
  112. package/dist/{web-state.js → web/web-state.js} +107 -9
  113. package/dist/webui-assets/dashboard.css +398 -49
  114. package/dist/webui-assets/dashboard.js +1239 -103
  115. package/dist/webui-assets/favicon.ico +0 -0
  116. package/dist/webui-assets/favicon.png +0 -0
  117. package/dist/webui-assets/logo.png +0 -0
  118. package/package.json +6 -3
  119. package/plugins/nordrelay/scripts/nordrelay.mjs +346 -12
  120. package/plugins/nordrelay/scripts/service-installer.mjs +183 -0
  121. package/{launchd/start.sh → scripts/launchd-start.sh} +1 -1
  122. package/scripts/postinstall.mjs +122 -0
  123. package/dist/relay-runtime.js +0 -1916
  124. package/dist/runtime-cache.js +0 -57
  125. package/dist/web-dashboard-ui.js +0 -20
  126. /package/dist/{user-management-crypto.js → access/user-management-crypto.js} +0 -0
  127. /package/dist/{user-management-normalize.js → access/user-management-normalize.js} +0 -0
  128. /package/dist/{user-management-types.js → access/user-management-types.js} +0 -0
  129. /package/dist/{claude-code-auth.js → agents/claude-code/claude-code-auth.js} +0 -0
  130. /package/dist/{claude-code-launch.js → agents/claude-code/claude-code-launch.js} +0 -0
  131. /package/dist/{claude-code-state.js → agents/claude-code/claude-code-state.js} +0 -0
  132. /package/dist/{codex-auth.js → agents/codex/codex-auth.js} +0 -0
  133. /package/dist/{codex-config.js → agents/codex/codex-config.js} +0 -0
  134. /package/dist/{codex-launch.js → agents/codex/codex-launch.js} +0 -0
  135. /package/dist/{codex-state.js → agents/codex/codex-state.js} +0 -0
  136. /package/dist/{hermes-api.js → agents/hermes/hermes-api.js} +0 -0
  137. /package/dist/{hermes-auth.js → agents/hermes/hermes-auth.js} +0 -0
  138. /package/dist/{hermes-state.js → agents/hermes/hermes-state.js} +0 -0
  139. /package/dist/{openclaw-auth.js → agents/openclaw/openclaw-auth.js} +0 -0
  140. /package/dist/{openclaw-gateway.js → agents/openclaw/openclaw-gateway.js} +0 -0
  141. /package/dist/{openclaw-state.js → agents/openclaw/openclaw-state.js} +0 -0
  142. /package/dist/{pi-auth.js → agents/pi/pi-auth.js} +0 -0
  143. /package/dist/{pi-rpc.js → agents/pi/pi-rpc.js} +0 -0
  144. /package/dist/{pi-state.js → agents/pi/pi-state.js} +0 -0
  145. /package/dist/{agent-adapter.js → agents/shared/agent-adapter.js} +0 -0
  146. /package/dist/{agent.js → agents/shared/agent.js} +0 -0
  147. /package/dist/{artifacts.js → artifacts/artifacts.js} +0 -0
  148. /package/dist/{attachments.js → artifacts/attachments.js} +0 -0
  149. /package/dist/{voice.js → artifacts/voice.js} +0 -0
  150. /package/dist/{discord-rate-limit.js → channels/discord/discord-rate-limit.js} +0 -0
  151. /package/dist/{channel-adapter.js → channels/shared/channel-adapter.js} +0 -0
  152. /package/dist/{relay-runtime-types.js → channels/shared/channel-bridge-state.js} +0 -0
  153. /package/dist/{channel-command-catalog.js → channels/shared/channel-command-catalog.js} +0 -0
  154. /package/dist/{channel-command-core.js → channels/shared/channel-command-core.js} +0 -0
  155. /package/dist/{channel-prompt-engine.js → channels/shared/channel-prompt-engine.js} +0 -0
  156. /package/dist/{channel-runtime.js → channels/shared/channel-runtime.js} +0 -0
  157. /package/dist/{channel-turn-lifecycle.js → channels/shared/channel-turn-lifecycle.js} +0 -0
  158. /package/dist/{slack-rate-limit.js → channels/slack/slack-rate-limit.js} +0 -0
  159. /package/dist/{telegram-command-types.js → channels/telegram/telegram-command-types.js} +0 -0
  160. /package/dist/{telegram-rate-limit.js → channels/telegram/telegram-rate-limit.js} +0 -0
  161. /package/dist/{activity-events.js → core/activity-events.js} +0 -0
  162. /package/dist/{error-messages.js → core/error-messages.js} +0 -0
  163. /package/dist/{format.js → core/format.js} +0 -0
  164. /package/dist/{logger.js → core/logger.js} +0 -0
  165. /package/dist/{redaction.js → core/redaction.js} +0 -0
  166. /package/dist/{settings-service.js → core/settings-service.js} +0 -0
  167. /package/dist/{settings-wizard-test.js → core/settings-wizard-test.js} +0 -0
  168. /package/dist/{workspace-policy.js → core/workspace-policy.js} +0 -0
  169. /package/dist/{peer-auth.js → peers/peer-auth.js} +0 -0
  170. /package/dist/{peer-client.js → peers/peer-client.js} +0 -0
  171. /package/dist/{peer-context.js → peers/peer-context.js} +0 -0
  172. /package/dist/{peer-readiness.js → peers/peer-readiness.js} +0 -0
  173. /package/dist/{web-api-types.js → runtime/relay-runtime-delegate.js} +0 -0
  174. /package/dist/{remote-prompt.js → runtime/remote-prompt.js} +0 -0
  175. /package/dist/{bot-preferences.js → state/bot-preferences.js} +0 -0
  176. /package/dist/{job-store.js → state/job-store.js} +0 -0
  177. /package/dist/{persistence.js → state/persistence.js} +0 -0
  178. /package/dist/{state-backend.js → state/state-backend.js} +0 -0
  179. /package/dist/{zip-writer.js → support/zip-writer.js} +0 -0
@@ -9,6 +9,7 @@
9
9
  { path: "/api/progress", methods: ["GET"] },
10
10
  { path: "/api/metrics", methods: ["GET"] },
11
11
  { path: "/api/jobs", methods: ["GET"] },
12
+ { path: "/api/trace", methods: ["GET"] },
12
13
  { re: /^\/api\/jobs\/[^\/]+\/log$/, methods: ["GET"] },
13
14
  { re: /^\/api\/jobs\/[^\/]+\/action$/, methods: ["POST"] },
14
15
  { path: "/api/active-sessions", methods: ["GET"] },
@@ -25,8 +26,17 @@
25
26
  { path: "/api/peers/invite", methods: ["POST"] },
26
27
  { path: "/api/peers/pair", methods: ["POST"] },
27
28
  { path: "/api/peers/probe", methods: ["POST"] },
29
+ { path: "/api/peers/discover", methods: ["GET"] },
30
+ { path: "/api/peers/discovery-jobs", methods: ["GET", "POST"] },
31
+ { re: /^\/api\/peers\/discovery-jobs\/[^\/]+$/, methods: ["GET"] },
32
+ { re: /^\/api\/peers\/discovery-jobs\/[^\/]+\/cancel$/, methods: ["POST"] },
33
+ { re: /^\/api\/peers\/discovery-jobs\/[^\/]+\/log$/, methods: ["GET"] },
34
+ { path: "/api/peers/identity/backup", methods: ["GET"] },
35
+ { path: "/api/peers/identity/restore", methods: ["POST"] },
28
36
  { path: "/api/peers/global-sessions", methods: ["GET"] },
29
37
  { re: /^\/api\/peers\/invitations\/[^\/]+$/, methods: ["DELETE"] },
38
+ { re: /^\/api\/peers\/[^\/]+\/repin$/, methods: ["POST"] },
39
+ { re: /^\/api\/peers\/[^\/]+\/rotate$/, methods: ["POST"] },
30
40
  { re: /^\/api\/peers\/[^\/]+\/health$/, methods: ["GET"] },
31
41
  { re: /^\/api\/peers\/[^\/]+$/, methods: ["PATCH", "DELETE"] },
32
42
  { re: /^\/api\/peers\/[^\/]+\/proxy$/, methods: ["POST"] },
@@ -79,6 +89,7 @@
79
89
  { path: "/api/sync", methods: ["POST"] },
80
90
  { path: "/api/queue", methods: ["GET", "POST"] },
81
91
  { path: "/api/chat/history", methods: ["GET", "DELETE"] },
92
+ { path: "/api/chat/mirror", methods: ["GET", "POST"] },
82
93
  { path: "/api/activity", methods: ["GET"] },
83
94
  { path: "/api/artifacts", methods: ["GET", "DELETE"] },
84
95
  { path: "/api/artifacts/bulk", methods: ["POST"] },
@@ -102,6 +113,10 @@
102
113
  assertApiRoute(url.pathname, method);
103
114
  if (!options.local && shouldProxyApi(url.pathname)) {
104
115
  const peerId = selectedPeerTarget();
116
+ const csrfToken2 = (
117
+ /** @type {{ NORDRELAY_WEBUI_RUNTIME_STATE?: { csrfToken?: string | null } }} */
118
+ globalThis.NORDRELAY_WEBUI_RUNTIME_STATE?.csrfToken
119
+ );
105
120
  const proxyBody = JSON.stringify({
106
121
  method,
107
122
  path: url.pathname,
@@ -111,7 +126,7 @@
111
126
  });
112
127
  const res2 = await fetch("/api/peers/" + encodeURIComponent(peerId) + "/proxy", {
113
128
  method: "POST",
114
- headers: { "content-type": "application/json" },
129
+ headers: { "content-type": "application/json", ...csrfToken2 ? { "x-nordrelay-csrf": csrfToken2 } : {} },
115
130
  body: proxyBody
116
131
  });
117
132
  if (res2.status === 401) {
@@ -127,8 +142,13 @@
127
142
  return data2;
128
143
  }
129
144
  const body = normalizeBody(options.body);
145
+ const csrfToken = (
146
+ /** @type {{ NORDRELAY_WEBUI_RUNTIME_STATE?: { csrfToken?: string | null } }} */
147
+ globalThis.NORDRELAY_WEBUI_RUNTIME_STATE?.csrfToken
148
+ );
130
149
  const headers = {
131
150
  ...body !== void 0 && shouldSendJsonHeader(options.body) ? { "content-type": "application/json" } : {},
151
+ ...method !== "GET" && csrfToken ? { "x-nordrelay-csrf": csrfToken } : {},
132
152
  ...options.headers || {}
133
153
  };
134
154
  const res = await fetch(url.pathname + url.search, { method, headers, body });
@@ -148,7 +168,7 @@
148
168
  const peerId = selectedPeerTarget();
149
169
  if (!peerId || peerId === "local") return false;
150
170
  if (!path.startsWith("/api/")) return false;
151
- return !(path === "/api/auth/me" || path === "/api/dashboard/logout" || path === "/api/peers" || path === "/api/peers/invite" || path === "/api/peers/pair" || path === "/api/peers/probe" || /^\/api\/peers\/[^/]+(?:\/events|\/proxy)?$/.test(path) || isLocalAdminApi(path));
171
+ return !(path === "/api/auth/me" || path === "/api/dashboard/logout" || path === "/api/peers" || path === "/api/peers/invite" || path === "/api/peers/pair" || path === "/api/peers/probe" || path === "/api/peers/discover" || path === "/api/peers/discovery-jobs" || path === "/api/peers/global-sessions" || path === "/api/peers/identity/backup" || path === "/api/peers/identity/restore" || path === "/api/settings/wizard/test" || /^\/api\/peers\/discovery-jobs\//.test(path) || /^\/api\/peers\/[^/]+(?:\/events|\/proxy)?$/.test(path) || /^\/api\/peers\/[^/]+\/repin$/.test(path) || /^\/api\/peers\/[^/]+\/rotate$/.test(path) || isLocalAdminApi(path));
152
172
  }
153
173
  function isLocalAdminApi(path) {
154
174
  return path === "/api/permissions" || path === "/api/settings" || path === "/api/audit" || path === "/api/locks" || path === "/api/users" || path === "/api/groups" || path === "/api/telegram-chats" || path === "/api/discord-channels" || path === "/api/slack-channels" || /^\/api\/users\//.test(path) || /^\/api\/groups\//.test(path) || /^\/api\/telegram-chats\//.test(path) || /^\/api\/discord-channels\//.test(path) || /^\/api\/slack-channels\//.test(path);
@@ -230,20 +250,39 @@
230
250
  throw new Error("Unsupported WebUI API method: " + method + " " + path);
231
251
  }
232
252
  }
233
- const state = { snapshot: null, controls: null, newSessionControls: null, enabledAgents: [], auth: null, permissions: [], settings: [], currentPage: "overview", settingsGroup: null, settingsWizard: null, accessTab: "users", logsPlain: "", logTimer: null, toastTimer: null, cliStatusActive: false, selectedArtifactTurns: /* @__PURE__ */ new Set(), mediaRecorder: null, recordedChunks: [], events: null, reconnectTimer: null, notifications: false, toolTooltipTimer: null, toolTooltipTarget: null, agentUpdateJobs: [], sessionsRequestId: 0, activeSessions: null, peers: null, peerInviteSecrets: {}, peerProbeResult: null, selectedPeer: localStorage.getItem("nordrelayPeerTarget") || "local" };
253
+ const state = { snapshot: null, controls: null, newSessionControls: null, enabledAgents: [], auth: null, csrfToken: null, permissions: [], settings: [], currentPage: "overview", settingsGroup: null, settingsWizard: null, accessTab: "users", logsPlain: "", logTimer: null, toastTimer: null, stickyToastActive: false, stickyToastText: "", cliStatusActive: false, webMirror: null, selectedArtifactTurns: /* @__PURE__ */ new Set(), mediaRecorder: null, recordedChunks: [], events: null, reconnectTimer: null, notifications: false, toolTooltipTimer: null, toolTooltipTarget: null, toolsVisible: false, agentUpdateJobs: [], sessionsRequestId: 0, chatHistoryRequestId: 0, chatRenderVersion: 0, activeSessions: null, peers: null, peerInviteSecrets: {}, peerProbeResult: null, peerDiscoveryJobs: [], selectedPeer: localStorage.getItem("nordrelayPeerTarget") || "local" };
234
254
  globalThis.NORDRELAY_WEBUI_RUNTIME_STATE = state;
255
+ const PAGE_LABELS = { overview: "Overview", chat: "Chat", sessions: "Sessions", queue: "Queue", tasks: "Tasks", metrics: "Metrics", activity: "Activity", trace: "Trace", artifacts: "Artifacts", adapters: "Adapters", peers: "Peers", access: "Users", version: "Version", settings: "Settings", logs: "Logs", diagnostics: "Diagnostics" };
256
+ const NAV_OPEN_STORAGE_KEY = "nordrelayNavOpenSections";
235
257
  function toast(msg, options = {}) {
236
258
  const el = document.getElementById("toast");
237
- el.textContent = msg;
238
- el.style.display = "block";
259
+ const text = String(msg ?? "");
239
260
  if (state.toastTimer) clearTimeout(state.toastTimer);
240
261
  state.toastTimer = null;
241
- if (!options.sticky) {
242
- state.toastTimer = setTimeout(() => {
243
- el.style.display = "none";
244
- state.toastTimer = null;
245
- }, options.duration || 3500);
262
+ if (options.sticky) {
263
+ state.stickyToastActive = true;
264
+ state.stickyToastText = text;
265
+ if (el.textContent !== text) el.textContent = text;
266
+ if (el.style.display !== "block") el.style.display = "block";
267
+ return;
246
268
  }
269
+ el.textContent = text;
270
+ el.style.display = "block";
271
+ state.toastTimer = setTimeout(() => {
272
+ state.toastTimer = null;
273
+ if (state.stickyToastActive) {
274
+ el.textContent = state.stickyToastText;
275
+ el.style.display = "block";
276
+ return;
277
+ }
278
+ el.style.display = "none";
279
+ }, options.duration || 3500);
280
+ }
281
+ function clearStickyToast() {
282
+ state.stickyToastActive = false;
283
+ state.stickyToastText = "";
284
+ if (state.toastTimer) clearTimeout(state.toastTimer);
285
+ state.toastTimer = null;
247
286
  }
248
287
  function esc(s) {
249
288
  return String(s ?? "").replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
@@ -314,6 +353,7 @@
314
353
  el.hidden = !allowed;
315
354
  el.disabled = !allowed;
316
355
  });
356
+ syncNavSections();
317
357
  const currentButton = document.querySelector('nav button[data-page="' + cssEscape(state.currentPage) + '"]');
318
358
  if (currentButton && currentButton.hidden) {
319
359
  const first = [...document.querySelectorAll("nav button[data-page]")].find((b) => !b.hidden);
@@ -325,6 +365,7 @@
325
365
  ["#newSessionBtn,#attachBtn,#createSessionBtn", "sessions.write"],
326
366
  ["#retryBtn", "prompt.send"],
327
367
  ["#syncBtn,#handbackBtn", "sessions.write"],
368
+ ["#mirrorModeSelect", "settings.write"],
328
369
  ["#abortBtn", "prompt.abort"],
329
370
  ["#clearChatBtn", "sessions.write"],
330
371
  ["#saveSettingsBtn", "settings.write"],
@@ -334,7 +375,8 @@
334
375
  ["#clearLogsBtn", "logs.clear"],
335
376
  ["#createUserBtn,#createGroupBtn,#createChatBtn,#createDiscordChannelBtn,#createSlackChannelBtn", "users.write"],
336
377
  ["#createPeerInviteBtn,#addPeerBtn,[data-peer-edit],[data-peer-toggle],[data-peer-revoke],[data-peer-invite-delete]", "peers.write"],
337
- ["#checkPeerReachabilityBtn,[data-peer-probe]", "peers.connect"],
378
+ ["#checkPeerReachabilityBtn,#discoverPeersBtn,#cancelPeerDiscoveryBtn,[data-peer-probe]", "peers.connect"],
379
+ ["#exportPeerIdentityBtn,#restorePeerIdentityBtn,[data-peer-repin],[data-peer-rotate]", "peers.write"],
338
380
  ["#lockSessionBtn,#unlockSessionBtn", "sessions.write"],
339
381
  ["[data-switch]", "sessions.write"],
340
382
  ["[data-queue],[data-q]", "queue.write"],
@@ -348,6 +390,59 @@
348
390
  if (!can(permission)) el.title = "Permission required: " + permission;
349
391
  }));
350
392
  }
393
+ function readOpenNavSections() {
394
+ try {
395
+ const raw = localStorage.getItem(NAV_OPEN_STORAGE_KEY);
396
+ if (!raw) return null;
397
+ const parsed = JSON.parse(raw);
398
+ return Array.isArray(parsed) ? new Set(parsed.filter(Boolean)) : null;
399
+ } catch {
400
+ return null;
401
+ }
402
+ }
403
+ function writeOpenNavSections() {
404
+ const open = [...document.querySelectorAll("[data-nav-section]")].filter((section) => section.dataset.navOpen === "true").map((section) => section.dataset.navSection).filter(Boolean);
405
+ localStorage.setItem(NAV_OPEN_STORAGE_KEY, JSON.stringify(open));
406
+ }
407
+ function setNavSectionOpen(sectionId, open, options = {}) {
408
+ const section = document.querySelector('[data-nav-section="' + cssEscape(sectionId) + '"]');
409
+ if (!section) return;
410
+ const items = section.querySelector(".nav-section-items");
411
+ const toggle = section.querySelector("[data-nav-toggle]");
412
+ section.dataset.navOpen = open ? "true" : "false";
413
+ if (items) items.hidden = !open;
414
+ if (toggle) toggle.setAttribute("aria-expanded", open ? "true" : "false");
415
+ if (options.persist !== false) writeOpenNavSections();
416
+ }
417
+ function sectionForPage(name) {
418
+ const button = document.querySelector('nav button[data-page="' + cssEscape(name) + '"]');
419
+ return button?.closest("[data-nav-section]")?.dataset.navSection || "";
420
+ }
421
+ function openSectionForPage(name, options = {}) {
422
+ const sectionId = sectionForPage(name);
423
+ if (sectionId) setNavSectionOpen(sectionId, true, options);
424
+ }
425
+ function syncNavSections() {
426
+ document.querySelectorAll("[data-nav-section]").forEach((section) => {
427
+ const visiblePages = [...section.querySelectorAll("button[data-page]")].filter((button) => !button.hidden);
428
+ const hasVisiblePages = visiblePages.length > 0;
429
+ section.hidden = !hasVisiblePages;
430
+ const active = visiblePages.some((button) => button.dataset.page === state.currentPage);
431
+ section.classList.toggle("active", active);
432
+ section.querySelector("[data-nav-toggle]")?.classList.toggle("active", active);
433
+ if (active && section.dataset.navOpen !== "true") setNavSectionOpen(section.dataset.navSection, true, { persist: false });
434
+ });
435
+ }
436
+ function initNavSections() {
437
+ const saved = readOpenNavSections();
438
+ document.querySelectorAll("[data-nav-section]").forEach((section) => {
439
+ const sectionId = section.dataset.navSection;
440
+ const open = saved ? saved.has(sectionId) : section.dataset.navDefaultOpen === "true";
441
+ setNavSectionOpen(sectionId, open, { persist: false });
442
+ });
443
+ openSectionForPage(state.currentPage, { persist: false });
444
+ syncNavSections();
445
+ }
351
446
  function modelLabel(m) {
352
447
  const meta = [m.contextWindow ? compactNum(m.contextWindow) : "", m.supportsImages === true ? "img" : m.supportsImages === false ? "text" : "", m.supportsThinking === true ? "think" : ""].filter(Boolean).join(" ");
353
448
  return (m.displayName || m.slug) + (meta ? " \xB7 " + meta : "");
@@ -360,10 +455,10 @@
360
455
  return Math.floor(min / 60) + "h ago";
361
456
  }
362
457
  function isCliRunningStatus(msg) {
363
- return / CLI running\\b/.test(String(msg || ""));
458
+ return / CLI running\b/.test(String(msg || ""));
364
459
  }
365
460
  function isCliDoneStatus(msg) {
366
- return / CLI task\\b/.test(String(msg || ""));
461
+ return / CLI task (?:finished|completed|failed|aborted)\b/i.test(String(msg || ""));
367
462
  }
368
463
  function applyTheme(theme) {
369
464
  document.documentElement.dataset.theme = theme;
@@ -373,11 +468,28 @@
373
468
  function toggleTheme() {
374
469
  applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
375
470
  }
471
+ function setToolsVisible(visible) {
472
+ state.toolsVisible = Boolean(visible);
473
+ const layout = document.getElementById("chatLayout");
474
+ const panel = document.getElementById("toolPanel");
475
+ const button = document.getElementById("toggleToolsBtn");
476
+ layout?.classList.toggle("tools-hidden", !state.toolsVisible);
477
+ if (panel) panel.hidden = !state.toolsVisible;
478
+ if (button) {
479
+ button.textContent = state.toolsVisible ? "Hide Tools" : "Show Tools";
480
+ button.setAttribute("aria-expanded", state.toolsVisible ? "true" : "false");
481
+ }
482
+ }
483
+ function toggleTools() {
484
+ setToolsVisible(!state.toolsVisible);
485
+ }
376
486
  function page(name) {
377
487
  state.currentPage = name;
378
- document.querySelectorAll("nav button").forEach((b) => b.classList.toggle("active", b.dataset.page === name));
488
+ openSectionForPage(name);
489
+ document.querySelectorAll("nav button[data-page]").forEach((b) => b.classList.toggle("active", b.dataset.page === name));
490
+ syncNavSections();
379
491
  document.querySelectorAll(".page").forEach((p) => p.classList.toggle("active", p.id === "page-" + name));
380
- document.getElementById("pageTitle").textContent = name[0].toUpperCase() + name.slice(1);
492
+ document.getElementById("pageTitle").textContent = PAGE_LABELS[name] || name[0].toUpperCase() + name.slice(1);
381
493
  document.getElementById("sidebar").classList.remove("open");
382
494
  void reloadCurrentPage().catch((err) => toast(err.message || String(err)));
383
495
  }
@@ -385,8 +497,8 @@
385
497
  const name = state.currentPage;
386
498
  if (name === "overview") await loadActiveSessions();
387
499
  if (name === "chat") {
388
- await loadChatHistory();
389
- scrollChatToBottom();
500
+ const [historyRendered] = await Promise.all([loadChatHistory({ forceScroll: true }), loadMirrorPreference()]);
501
+ if (historyRendered) scrollChatToBottom({ force: true });
390
502
  }
391
503
  if (name === "sessions") await loadSessions(true, options.agentId);
392
504
  if (name === "settings") await loadSettings();
@@ -394,6 +506,7 @@
394
506
  if (name === "diagnostics") await loadDiagnostics();
395
507
  if (name === "artifacts") await loadArtifacts();
396
508
  if (name === "activity") await loadActivity();
509
+ if (name === "trace") renderTracePlaceholder();
397
510
  if (name === "tasks") await loadTasks();
398
511
  if (name === "metrics") await loadMetrics();
399
512
  if (name === "adapters") await loadAdapterHealth();
@@ -401,15 +514,46 @@
401
514
  if (name === "access") await loadAccess();
402
515
  if (name === "version") await loadVersion();
403
516
  }
404
- document.querySelectorAll("nav button").forEach((b) => b.onclick = () => page(b.dataset.page));
517
+ document.querySelectorAll("nav button[data-page]").forEach((b) => b.onclick = () => page(b.dataset.page));
518
+ document.querySelectorAll("[data-nav-toggle]").forEach((b) => b.onclick = () => {
519
+ const sectionId = b.dataset.navToggle;
520
+ const section = document.querySelector('[data-nav-section="' + cssEscape(sectionId) + '"]');
521
+ setNavSectionOpen(sectionId, section?.dataset.navOpen !== "true");
522
+ syncNavSections();
523
+ });
524
+ initNavSections();
405
525
  document.getElementById("menuBtn").onclick = () => document.getElementById("sidebar").classList.toggle("open");
406
- document.getElementById("refreshBtn").onclick = () => loadBootstrap();
407
526
  document.getElementById("themeBtn").onclick = toggleTheme;
527
+ document.getElementById("toggleToolsBtn").onclick = toggleTools;
408
528
  document.getElementById("logoutBtn").onclick = () => safe(async () => {
409
529
  await api("/api/dashboard/logout", { method: "POST" });
410
530
  location.href = "/";
411
531
  });
412
532
  applyTheme(localStorage.getItem("nordrelayTheme") || "light");
533
+ setToolsVisible(false);
534
+ function uiBadge(text, status = "enabled") {
535
+ return '<span class="adapter-status ' + esc(status) + '">' + esc(text) + "</span>";
536
+ }
537
+ function uiRows(rows = []) {
538
+ return rows.filter(Boolean).map((row) => Array.isArray(row) ? "<small>" + esc(row[0]) + ": " + esc(row[1] ?? "-") + "</small>" : "<small>" + esc(row) + "</small>").join("");
539
+ }
540
+ function uiItem(title, options = {}) {
541
+ const badge = options.badge ? uiBadge(options.badge.text, options.badge.status) : "";
542
+ const rows = uiRows(options.rows || []);
543
+ const body = options.body || "";
544
+ const actions = options.actions ? '<div class="row">' + options.actions + "</div>" : "";
545
+ const titleAttr = options.title ? ' title="' + attr(options.title) + '"' : "";
546
+ return '<div class="item ' + (options.className ? attr(options.className) : "") + '"><strong' + titleAttr + ">" + esc(title) + " " + badge + "</strong>" + rows + body + actions + "</div>";
547
+ }
548
+ function uiEmpty(text) {
549
+ return '<div class="item">' + esc(text) + "</div>";
550
+ }
551
+ function uiCopyButton(value, label = "Copied", className = "copy-id") {
552
+ return value ? '<button type="button" class="' + attr(className) + '" data-copy-value="' + attr(value) + '" data-copy-label="' + attr(label) + '">' + esc(value) + "</button>" : "-";
553
+ }
554
+ function bindUiCopyButtons(root = document) {
555
+ root.querySelectorAll?.("[data-copy-value]").forEach((b) => b.onclick = () => copyText(b.dataset.copyValue || "", b.dataset.copyLabel || "Copied"));
556
+ }
413
557
  function createPaginator(containerId, onChange, pageSize = 50) {
414
558
  const container = document.getElementById(containerId);
415
559
  return {
@@ -440,9 +584,53 @@
440
584
  };
441
585
  }
442
586
  const sessionsPager = createPaginator("sessionsPager", () => loadSessions(false), 50);
587
+ function createCursorPager(containerId, onChange) {
588
+ const container = document.getElementById(containerId);
589
+ return {
590
+ stack: [],
591
+ cursor: null,
592
+ nextCursor: null,
593
+ hasNext: false,
594
+ total: 0,
595
+ reset() {
596
+ this.stack = [];
597
+ this.cursor = null;
598
+ this.nextCursor = null;
599
+ this.hasNext = false;
600
+ this.total = 0;
601
+ },
602
+ render(meta = {}) {
603
+ if (!container) return;
604
+ this.nextCursor = meta.nextCursor || null;
605
+ this.hasNext = Boolean(meta.hasNext);
606
+ this.total = Number(meta.total || 0);
607
+ container.innerHTML = "<span>" + esc(this.total ? this.total + " total" : "") + '</span><div class="pager-actions"><button data-cursor-action="prev" ' + (!this.stack.length ? "disabled" : "") + '>Previous</button><button data-cursor-action="next" ' + (!this.hasNext ? "disabled" : "") + ">Next</button></div>";
608
+ const prev = container.querySelector('[data-cursor-action="prev"]');
609
+ const next = container.querySelector('[data-cursor-action="next"]');
610
+ prev.onclick = () => {
611
+ if (this.stack.length) {
612
+ this.cursor = this.stack.pop() || null;
613
+ onChange();
614
+ }
615
+ };
616
+ next.onclick = () => {
617
+ if (this.hasNext && this.nextCursor) {
618
+ this.stack.push(this.cursor);
619
+ this.cursor = this.nextCursor;
620
+ onChange();
621
+ }
622
+ };
623
+ }
624
+ };
625
+ }
626
+ const activityPager = createCursorPager("activityPager", () => loadActivity(false));
627
+ const auditPager = createCursorPager("auditPager", () => loadAudit(false));
628
+ const artifactPager = createCursorPager("artifactPager", () => loadArtifacts(false));
629
+ const jobsPager = createCursorPager("jobsPager", () => loadTasks(false));
443
630
  async function loadBootstrap() {
444
631
  const local = await api("/api/bootstrap", { local: true });
445
632
  state.auth = local.auth || null;
633
+ state.csrfToken = local.auth?.csrfToken || state.csrfToken || null;
446
634
  state.permissions = local.auth?.permissions || [];
447
635
  await loadPeerSelector();
448
636
  const data = state.selectedPeer && state.selectedPeer !== "local" ? await api("/api/bootstrap") : local;
@@ -562,14 +750,14 @@
562
750
  }
563
751
  function activeSessionCard(s) {
564
752
  const thread = s.threadId || "not started";
565
- const prompt = s.prompt ? "<small>" + esc(short(s.prompt, 250)) + "</small>" : "";
753
+ const prompt2 = s.prompt ? "<small>" + esc(short(s.prompt, 250)) + "</small>" : "";
566
754
  const tool2 = s.currentTool || s.lastTool || "-";
567
755
  const queue = s.queueLength ? " \xB7 " + s.queueLength + " queued" + (s.queuePaused ? " paused" : "") : "";
568
756
  const sourceLabel = activeSourceLabel(s.source);
569
757
  const mirrors = (s.mirrorChannels || []).map((m) => activeSourceLabel(m.source) + " " + m.mode + (m.queueLength ? " \xB7 " + m.queueLength + " queued" + (m.queuePaused ? " paused" : "") : "")).join(", ");
570
758
  const meta = ["Source " + sourceLabel, s.workspace, fmtDuration(s.durationMs), tool2 && tool2 !== "-" ? "tool " + tool2 : ""].filter(Boolean).join(" | ");
571
759
  const mirrorLine = mirrors ? "<small>Mirroring: " + esc(mirrors) + "</small>" : s.source === "cli" ? "<small>Mirroring: none</small>" : "";
572
- return '<div class="item active-session-item"><strong>' + esc(s.agentLabel || s.agentId || "Agent") + ' <span class="adapter-status enabled">' + esc(s.status) + '</span></strong><small><button type="button" class="copy-id" data-active-copy="' + attr(thread) + '" title="Copy thread ID">' + esc(short(thread, 64)) + "</button>" + esc(queue) + "</small><small>" + esc(meta) + "</small>" + mirrorLine + prompt + '<div class="row"><button data-active-switch="' + attr(thread) + '" data-active-agent="' + attr(s.agentId || "") + '" ' + (!s.threadId ? "disabled " : "") + disabledAttr("sessions.write") + '>Switch</button><button class="secondary" data-active-detail="' + attr(thread) + '" data-active-agent="' + attr(s.agentId || "") + '" ' + (!s.threadId ? "disabled " : "") + ">Details</button></div></div>";
760
+ return '<div class="item active-session-item"><strong>' + esc(s.agentLabel || s.agentId || "Agent") + ' <span class="adapter-status enabled">' + esc(s.status) + '</span></strong><small><button type="button" class="copy-id" data-active-copy="' + attr(thread) + '" title="Copy thread ID">' + esc(short(thread, 64)) + "</button>" + esc(queue) + "</small><small>" + esc(meta) + "</small>" + mirrorLine + prompt2 + '<div class="row"><button data-active-switch="' + attr(thread) + '" data-active-agent="' + attr(s.agentId || "") + '" ' + (!s.threadId ? "disabled " : "") + disabledAttr("sessions.write") + '>Switch</button><button class="secondary" data-active-detail="' + attr(thread) + '" data-active-agent="' + attr(s.agentId || "") + '" ' + (!s.threadId ? "disabled " : "") + ">Details</button></div></div>";
573
761
  }
574
762
  function activeSourceLabel(source) {
575
763
  if (source === "cli") return "CLI";
@@ -672,9 +860,19 @@
672
860
  if (status === "running") return "planned";
673
861
  return "disabled";
674
862
  }
675
- function scrollChatToBottom() {
863
+ const CHAT_CODE_BLOCK_PREFIX = "\uE010C";
864
+ const CHAT_CODE_BLOCK_SUFFIX = "\uE010";
865
+ const CHAT_INLINE_CODE_PREFIX = "\uE011I";
866
+ const CHAT_INLINE_CODE_SUFFIX = "\uE011";
867
+ function isChatNearBottom() {
868
+ const box = document.getElementById("messages");
869
+ if (!box) return true;
870
+ return box.scrollHeight - box.scrollTop - box.clientHeight < 80;
871
+ }
872
+ function scrollChatToBottom(options = {}) {
676
873
  const box = document.getElementById("messages");
677
874
  if (!box) return;
875
+ if (!options.force && !isChatNearBottom()) return;
678
876
  requestAnimationFrame(() => {
679
877
  box.scrollTop = box.scrollHeight;
680
878
  requestAnimationFrame(() => {
@@ -682,39 +880,251 @@
682
880
  });
683
881
  });
684
882
  }
685
- function appendMessage(cls, text) {
883
+ function markChatRendered() {
884
+ state.chatRenderVersion = (state.chatRenderVersion || 0) + 1;
885
+ }
886
+ function appendMessage(cls, text, options = {}) {
686
887
  const box = document.getElementById("messages");
888
+ const stick = options.forceScroll || isChatNearBottom();
889
+ const previousTop = box?.scrollTop ?? 0;
687
890
  const div = document.createElement("div");
688
891
  div.className = "message " + cls;
689
- div.textContent = text;
892
+ const body = document.createElement("div");
893
+ body.className = "message-body";
894
+ div.appendChild(body);
895
+ setMessageText(div, text);
690
896
  box.appendChild(div);
691
- scrollChatToBottom();
897
+ if (stick) scrollChatToBottom({ force: true });
898
+ else box.scrollTop = previousTop;
899
+ return div;
900
+ }
901
+ function messageBody(div) {
902
+ let body = div.querySelector?.(".message-body");
903
+ if (!body) {
904
+ body = document.createElement("div");
905
+ body.className = "message-body";
906
+ div.textContent = "";
907
+ div.appendChild(body);
908
+ }
909
+ return body;
910
+ }
911
+ function setMessageText(div, text) {
912
+ div.__rawText = String(text ?? "");
913
+ const body = messageBody(div);
914
+ body.innerHTML = renderChatMarkdown(div.__rawText);
915
+ bindChatCopyButtons(body);
916
+ markChatRendered();
692
917
  return div;
693
918
  }
694
919
  function appendQueuedMessage(id) {
695
920
  const div = appendMessage("system", "Queued prompt " + id);
921
+ const body = messageBody(div);
696
922
  const btn = document.createElement("button");
697
923
  btn.textContent = "Cancel queued message";
698
924
  btn.className = "danger";
699
925
  btn.onclick = () => safe(async () => {
700
926
  const r = await api("/api/queue", { method: "POST", body: JSON.stringify({ action: "cancel", id }) });
701
927
  renderQueue(r.queue, r.paused);
702
- div.textContent = "Cancelled queued prompt " + id;
928
+ setMessageText(div, "Cancelled queued prompt " + id);
703
929
  });
704
- div.appendChild(document.createElement("br"));
705
- div.appendChild(btn);
930
+ body.appendChild(document.createElement("br"));
931
+ body.appendChild(btn);
706
932
  }
707
- function renderChatMessages(messages) {
933
+ function renderChatMessages(messages, options = {}) {
708
934
  state.chatMessages = messages || [];
709
935
  const box = document.getElementById("messages");
710
- box.innerHTML = (messages || []).map((m) => '<div class="message ' + esc(m.role) + '"><small>' + esc((m.source || "web") + " / " + fmtDate(m.timestamp)) + "</small>\\n" + esc(m.text) + "</div>").join("");
711
- scrollChatToBottom();
712
- }
713
- async function loadChatHistory() {
936
+ const stick = options.forceScroll || isChatNearBottom();
937
+ const previousTop = box?.scrollTop ?? 0;
938
+ box.innerHTML = (messages || []).map((m) => '<div class="message ' + esc(m.role) + '"><small>' + esc((m.source || "web") + " / " + fmtDate(m.timestamp)) + '</small><br><div class="message-body">' + renderChatMarkdown(m.text) + "</div></div>").join("");
939
+ bindChatCopyButtons(box);
940
+ markChatRendered();
941
+ if (stick) scrollChatToBottom({ force: true });
942
+ else box.scrollTop = previousTop;
943
+ }
944
+ async function loadChatHistory(options = {}) {
945
+ const requestId = (state.chatHistoryRequestId || 0) + 1;
946
+ state.chatHistoryRequestId = requestId;
947
+ const renderVersion = state.chatRenderVersion || 0;
714
948
  const data = await api("/api/chat/history");
715
- renderChatMessages(data.messages || []);
949
+ if (requestId !== state.chatHistoryRequestId) return false;
950
+ if (options.skipIfRendered !== false && (state.chatRenderVersion || 0) !== renderVersion) return false;
951
+ renderChatMessages(data.messages || [], options);
952
+ return true;
716
953
  }
717
954
  let currentAgentMessage = null;
955
+ function renderChatMarkdown(text) {
956
+ let output = esc(String(text ?? ""));
957
+ const codeBlocks = [];
958
+ const inlineCode = [];
959
+ output = extractChatCodeBlocks(output, codeBlocks);
960
+ output = extractChatInlineCode(output, inlineCode);
961
+ output = formatChatBold(output);
962
+ output = formatChatItalic(output);
963
+ output = formatChatLinks(output);
964
+ output = formatChatBlockquotes(output);
965
+ output = formatChatLists(output);
966
+ output = formatChatHeadings(output);
967
+ output = restoreChatMarkdown(output, CHAT_INLINE_CODE_PREFIX, CHAT_INLINE_CODE_SUFFIX, inlineCode);
968
+ output = restoreChatMarkdown(output, CHAT_CODE_BLOCK_PREFIX, CHAT_CODE_BLOCK_SUFFIX, codeBlocks);
969
+ return output;
970
+ }
971
+ function extractChatCodeBlocks(text, blocks) {
972
+ return text.replace(/```([^\n`]*)\n?([\s\S]*?)```/g, (_match, rawLanguage, rawCode) => {
973
+ const language = String(rawLanguage || "").trim().replace(/[^a-zA-Z0-9_+-]/g, "");
974
+ const className = language ? ' class="language-' + attr(language) + '"' : "";
975
+ const label = language ? ' data-code-language="' + attr(language) + '"' : "";
976
+ const block = '<pre class="chat-code-block" tabindex="0" title="Copy code" data-chat-copy="code-block"' + label + "><code" + className + ">" + rawCode + "</code></pre>";
977
+ const index = blocks.push(block) - 1;
978
+ return CHAT_CODE_BLOCK_PREFIX + index + CHAT_CODE_BLOCK_SUFFIX;
979
+ });
980
+ }
981
+ function extractChatInlineCode(text, inline) {
982
+ let result = "";
983
+ let index = 0;
984
+ while (index < text.length) {
985
+ if (text[index] !== "`") {
986
+ result += text[index];
987
+ index += 1;
988
+ continue;
989
+ }
990
+ let tickCount = 1;
991
+ while (text[index + tickCount] === "`") tickCount += 1;
992
+ const fence = "`".repeat(tickCount);
993
+ const start = index + tickCount;
994
+ const end = text.indexOf(fence, start);
995
+ if (end === -1) {
996
+ result += fence;
997
+ index += tickCount;
998
+ continue;
999
+ }
1000
+ const content = text.slice(start, end);
1001
+ if (content.includes("\n")) {
1002
+ result += fence;
1003
+ index += tickCount;
1004
+ continue;
1005
+ }
1006
+ const button = '<button type="button" class="chat-inline-code copy-id" title="Copy code" data-chat-copy="inline-code">' + content + "</button>";
1007
+ result += CHAT_INLINE_CODE_PREFIX + (inline.push(button) - 1) + CHAT_INLINE_CODE_SUFFIX;
1008
+ index = end + tickCount;
1009
+ }
1010
+ return result;
1011
+ }
1012
+ function formatChatBold(text) {
1013
+ return text.replace(/(?<!\*)\*\*(?!\s)([^\n]*?\S)\*\*(?!\*)/g, "<strong>$1</strong>");
1014
+ }
1015
+ function formatChatItalic(text) {
1016
+ return text.replace(/(?<![\w_])_(?!\s)([^_\n]*?\S)_(?![\w_])/g, "<em>$1</em>").replace(/(?<![\w*])\*(?!\s)([^*\n]*?\S)\*(?![\w*])/g, "<em>$1</em>");
1017
+ }
1018
+ function formatChatLinks(text) {
1019
+ return text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_match, label, url) => {
1020
+ const safeUrl = sanitizeChatUrl(String(url).replace(/&amp;/g, "&"));
1021
+ return '<a class="chat-link" href="' + attr(safeUrl) + '" target="_blank" rel="noreferrer noopener">' + label + "</a>";
1022
+ });
1023
+ }
1024
+ function sanitizeChatUrl(url) {
1025
+ const trimmed = String(url || "").trim().replace(/"/g, "%22");
1026
+ return /^(https?|mailto):/i.test(trimmed) ? trimmed : "#";
1027
+ }
1028
+ function formatChatBlockquotes(text) {
1029
+ const lines = text.split("\n");
1030
+ const out = [];
1031
+ let quote = [];
1032
+ const flush = () => {
1033
+ if (quote.length) {
1034
+ out.push('<blockquote class="chat-blockquote">' + quote.join("\n") + "</blockquote>");
1035
+ quote = [];
1036
+ }
1037
+ };
1038
+ for (const line of lines) {
1039
+ const match = line.match(/^&gt; ?(.*)$/);
1040
+ if (match) {
1041
+ quote.push(match[1]);
1042
+ continue;
1043
+ }
1044
+ flush();
1045
+ out.push(line);
1046
+ }
1047
+ flush();
1048
+ return out.join("\n");
1049
+ }
1050
+ function formatChatLists(text) {
1051
+ const lines = text.split("\n");
1052
+ const out = [];
1053
+ let list = null;
1054
+ const flush = () => {
1055
+ if (list) {
1056
+ out.push("</" + list + ">");
1057
+ list = null;
1058
+ }
1059
+ };
1060
+ for (const line of lines) {
1061
+ const task = line.match(/^\s*[-*]\s+\[([ xX])\]\s+(.+)$/);
1062
+ const bullet = line.match(/^\s*[-*]\s+(.+)$/);
1063
+ const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/);
1064
+ if (task) {
1065
+ if (list !== "ul") {
1066
+ flush();
1067
+ out.push('<ul class="chat-list chat-task-list">');
1068
+ list = "ul";
1069
+ }
1070
+ const checked = task[1].toLowerCase() === "x" ? "[x]" : "[ ]";
1071
+ out.push('<li><span class="chat-task-mark">' + checked + "</span> " + task[2] + "</li>");
1072
+ continue;
1073
+ }
1074
+ if (bullet) {
1075
+ if (list !== "ul") {
1076
+ flush();
1077
+ out.push('<ul class="chat-list">');
1078
+ list = "ul";
1079
+ }
1080
+ out.push("<li>" + bullet[1] + "</li>");
1081
+ continue;
1082
+ }
1083
+ if (ordered) {
1084
+ if (list !== "ol") {
1085
+ flush();
1086
+ out.push('<ol class="chat-list">');
1087
+ list = "ol";
1088
+ }
1089
+ out.push("<li>" + ordered[1] + "</li>");
1090
+ continue;
1091
+ }
1092
+ flush();
1093
+ out.push(line);
1094
+ }
1095
+ flush();
1096
+ return out.join("\n");
1097
+ }
1098
+ function formatChatHeadings(text) {
1099
+ return text.split("\n").map((line) => {
1100
+ const match = line.match(/^(#{1,4})\s+(.+)$/);
1101
+ return match ? '<strong class="chat-heading chat-heading-' + match[1].length + '">' + match[2] + "</strong>" : line;
1102
+ }).join("\n");
1103
+ }
1104
+ function restoreChatMarkdown(text, prefix, suffix, values) {
1105
+ const pattern = new RegExp(escapeChatRegExp(prefix) + "(\\d+)" + escapeChatRegExp(suffix), "g");
1106
+ return text.replace(pattern, (_match, rawIndex) => values[Number.parseInt(rawIndex, 10)] ?? "");
1107
+ }
1108
+ function escapeChatRegExp(text) {
1109
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1110
+ }
1111
+ function bindChatCopyButtons(root) {
1112
+ root.querySelectorAll?.("[data-chat-copy]").forEach((el) => {
1113
+ el.onclick = (event) => {
1114
+ event.preventDefault();
1115
+ event.stopPropagation();
1116
+ const code = el.dataset.chatCopy === "code-block" ? el.querySelector("code")?.textContent : el.textContent;
1117
+ copyText(code || "", "Copied code");
1118
+ };
1119
+ el.onkeydown = (event) => {
1120
+ if (el.tagName === "BUTTON") return;
1121
+ if (event.key === "Enter" || event.key === " ") {
1122
+ event.preventDefault();
1123
+ el.click();
1124
+ }
1125
+ };
1126
+ });
1127
+ }
718
1128
  function connectEvents() {
719
1129
  if (state.events) state.events.close();
720
1130
  const eventsUrl = state.selectedPeer && state.selectedPeer !== "local" ? "/api/peers/" + encodeURIComponent(state.selectedPeer) + "/events?contextKey=" + encodeURIComponent("web:dashboard") : "/api/events";
@@ -765,9 +1175,10 @@
765
1175
  });
766
1176
  events.addEventListener("text_delta", (e) => {
767
1177
  const d = JSON.parse(e.data);
1178
+ const stick = isChatNearBottom();
768
1179
  if (!currentAgentMessage) currentAgentMessage = appendMessage("agent", "");
769
- currentAgentMessage.textContent += d.delta;
770
- scrollChatToBottom();
1180
+ setMessageText(currentAgentMessage, (currentAgentMessage.__rawText || "") + d.delta);
1181
+ if (stick) scrollChatToBottom({ force: true });
771
1182
  if (state.currentPage === "tasks") loadTasks();
772
1183
  });
773
1184
  events.addEventListener("tool_start", (e) => {
@@ -785,7 +1196,7 @@
785
1196
  });
786
1197
  events.addEventListener("todo_update", (e) => {
787
1198
  const d = JSON.parse(e.data);
788
- tool("tool", "Plan:\\n" + d.items.map((i) => (i.completed ? "[x] " : "[ ] ") + i.text).join("\\n"));
1199
+ tool("tool", "Plan:\n" + d.items.map((i) => (i.completed ? "[x] " : "[ ] ") + i.text).join("\n"));
789
1200
  });
790
1201
  events.addEventListener("turn_error", (e) => {
791
1202
  const d = JSON.parse(e.data);
@@ -806,7 +1217,10 @@
806
1217
  toast(msg, { sticky: true });
807
1218
  return;
808
1219
  }
809
- if (isCliDoneStatus(msg)) state.cliStatusActive = false;
1220
+ if (isCliDoneStatus(msg)) {
1221
+ state.cliStatusActive = false;
1222
+ clearStickyToast();
1223
+ }
810
1224
  toast(msg);
811
1225
  });
812
1226
  events.onerror = () => {
@@ -971,6 +1385,27 @@
971
1385
  });
972
1386
  document.getElementById("promptForm").onsubmit = (e) => safe(async () => {
973
1387
  e.preventDefault();
1388
+ const input = document.getElementById("promptInput");
1389
+ const text = input.value.trim();
1390
+ if (/^\/mirror\b/i.test(text)) {
1391
+ if (selectedFiles.length) {
1392
+ toast("/mirror cannot be sent with attachments");
1393
+ return;
1394
+ }
1395
+ const argument = text.replace(/^\/mirror\b/i, "").trim();
1396
+ if (argument && !can("settings.write")) {
1397
+ toast("Permission required: settings.write");
1398
+ return;
1399
+ }
1400
+ if (!argument && !can("sessions.read")) {
1401
+ toast("Permission required: sessions.read");
1402
+ return;
1403
+ }
1404
+ input.value = "";
1405
+ const data = argument ? await setMirrorPreference(argument) : await loadMirrorPreference();
1406
+ appendMessage("system", data?.response?.plain || "CLI mirroring: " + (data?.mode || "-"));
1407
+ return;
1408
+ }
974
1409
  if (!can("prompt.send")) {
975
1410
  toast("Permission required: prompt.send");
976
1411
  return;
@@ -979,8 +1414,6 @@
979
1414
  toast("Permission required: files.write");
980
1415
  return;
981
1416
  }
982
- const input = document.getElementById("promptInput");
983
- const text = input.value.trim();
984
1417
  if (!text && selectedFiles.length === 0) return;
985
1418
  const files = selectedFiles;
986
1419
  input.value = "";
@@ -989,7 +1422,7 @@
989
1422
  renderSelectedFiles();
990
1423
  const payloadFiles = files.length ? await Promise.all(files.map(filePayload)) : [];
991
1424
  const r = files.length ? await api("/api/prompt/upload", { method: "POST", body: JSON.stringify({ text, files: payloadFiles }) }) : await api("/api/prompt", { method: "POST", body: JSON.stringify({ text }) });
992
- if (r.transcribeOnly) appendMessage("system", "Transcribed audio:\\n" + (r.transcript || "(empty)"));
1425
+ if (r.transcribeOnly) appendMessage("system", "Transcribed audio:\n" + (r.transcript || "(empty)"));
993
1426
  else if (r.queued) appendQueuedMessage(r.queueId);
994
1427
  }, e);
995
1428
  document.getElementById("newSessionBtn").onclick = () => {
@@ -1024,6 +1457,14 @@
1024
1457
  loadBootstrap();
1025
1458
  });
1026
1459
  document.getElementById("notifyBtn").onclick = () => enableNotifications();
1460
+ document.getElementById("mirrorModeSelect").onchange = () => safe(async () => {
1461
+ if (!can("settings.write")) {
1462
+ toast("Permission required: settings.write");
1463
+ renderMirrorPreference(state.webMirror);
1464
+ return;
1465
+ }
1466
+ await setMirrorPreference(document.getElementById("mirrorModeSelect").value);
1467
+ });
1027
1468
  document.getElementById("clearChatBtn").onclick = () => safe(async () => {
1028
1469
  if (!can("sessions.write")) {
1029
1470
  toast("Permission required: sessions.write");
@@ -1049,7 +1490,7 @@
1049
1490
  return;
1050
1491
  }
1051
1492
  const r = await api("/api/handback", { method: "POST" });
1052
- appendMessage("system", "Handback command:\\n" + (r.command || "No command available"));
1493
+ appendMessage("system", "Handback command:\n" + (r.command || "No command available"));
1053
1494
  });
1054
1495
  document.getElementById("recordBtn").onclick = () => safe(async () => {
1055
1496
  if (!can("files.write")) {
@@ -1077,6 +1518,24 @@
1077
1518
  state.mediaRecorder.start();
1078
1519
  btn.textContent = "Stop recording";
1079
1520
  });
1521
+ function renderMirrorPreference(data) {
1522
+ if (!data) return;
1523
+ state.webMirror = data;
1524
+ const select = document.getElementById("mirrorModeSelect");
1525
+ if (select && data.mode) select.value = data.mode;
1526
+ }
1527
+ async function loadMirrorPreference() {
1528
+ if (!can("sessions.read")) return null;
1529
+ const data = await api("/api/chat/mirror");
1530
+ renderMirrorPreference(data);
1531
+ return data;
1532
+ }
1533
+ async function setMirrorPreference(argument) {
1534
+ const data = await api("/api/chat/mirror", { method: "POST", body: JSON.stringify({ argument }) });
1535
+ renderMirrorPreference(data);
1536
+ toast("Mirror " + data.mode);
1537
+ return data;
1538
+ }
1080
1539
  function renderNewSessionControls(c) {
1081
1540
  const s = state.snapshot?.session || {};
1082
1541
  const caps = c.capabilities || {};
@@ -1120,7 +1579,7 @@
1120
1579
  document.getElementById("newSessionDialog").close();
1121
1580
  toast("New session started");
1122
1581
  await loadBootstrap();
1123
- await loadChatHistory();
1582
+ await loadChatHistory({ forceScroll: true });
1124
1583
  }, e);
1125
1584
  document.getElementById("cancelSessionBtn").onclick = () => document.getElementById("newSessionDialog").close();
1126
1585
  function val(id) {
@@ -1193,7 +1652,8 @@
1193
1652
  };
1194
1653
  function renderQueue(queue, paused) {
1195
1654
  document.getElementById("queueStatus").textContent = paused ? "Paused" : "Running";
1196
- document.getElementById("queueList").innerHTML = (queue || []).map((q, i) => '<div class="item queue-item" draggable="true" data-queue-id="' + attr(q.id) + '"><strong>' + esc(i + 1 + ". " + q.id + " - " + q.description) + "</strong><small>Created " + fmtDate(q.createdAt) + " / attempts " + q.attempts + (q.lastError ? " / " + esc(q.lastError) : "") + '</small><div class="row"><button data-q="run" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Run</button><button data-q="top" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Top</button><button data-q="up" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Up</button><button data-q="down" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Down</button><button data-q="cancel" data-id="' + q.id + '" class="danger"' + disabledAttr("queue.write") + ">Cancel</button></div></div>").join("") || '<div class="item">Queue is empty.</div>';
1655
+ document.getElementById("queueList").innerHTML = (queue || []).map((q, i) => '<div class="item queue-item" draggable="true" data-queue-id="' + attr(q.id) + '"><strong>' + esc(i + 1 + ". " + q.id + " - " + q.description) + "</strong><small>Created " + fmtDate(q.createdAt) + " / attempts " + q.attempts + (q.correlationId ? ' / CID: <button type="button" class="copy-id" data-copy-id="' + attr(q.correlationId) + '">' + esc(q.correlationId) + "</button>" : "") + (q.lastError ? " / " + esc(q.lastError) : "") + '</small><div class="row"><button data-q="run" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Run</button><button data-q="top" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Top</button><button data-q="up" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Up</button><button data-q="down" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Down</button><button data-q="cancel" data-id="' + q.id + '" class="danger"' + disabledAttr("queue.write") + ">Cancel</button></div></div>").join("") || '<div class="item">Queue is empty.</div>';
1656
+ document.querySelectorAll("#queueList [data-copy-id]").forEach((b) => b.onclick = () => copyText(b.dataset.copyId || "", "Correlation ID copied"));
1197
1657
  document.querySelectorAll("[data-q]").forEach((b) => b.onclick = () => safe(async () => {
1198
1658
  if (!can("queue.write")) {
1199
1659
  toast("Permission required: queue.write");
@@ -1238,12 +1698,14 @@
1238
1698
  const r = await api("/api/queue", { method: "POST", body: JSON.stringify({ action: b.dataset.queue }) });
1239
1699
  renderQueue(r.queue, r.paused);
1240
1700
  }));
1241
- async function loadArtifacts() {
1701
+ async function loadArtifacts(reset = true) {
1702
+ if (reset) artifactPager.reset();
1242
1703
  setLoading("artifactList", "Loading artifacts...");
1243
1704
  document.getElementById("artifactPreview").innerHTML = "";
1244
- const data = await api("/api/artifacts");
1705
+ const data = await api("/api/artifacts", { query: { limit: 50, cursor: artifactPager.cursor || void 0 } });
1245
1706
  state.artifactReports = data.reports || [];
1246
1707
  renderArtifacts();
1708
+ artifactPager.render(data.pagination || {});
1247
1709
  }
1248
1710
  function artifactMatches(a, kind, query) {
1249
1711
  const name = (a.name || a.relativePath || "").toLowerCase();
@@ -1252,7 +1714,7 @@
1252
1714
  if (kind === "docs") return !/\\.(png|jpe?g|gif|webp|svg)$/i.test(name);
1253
1715
  return true;
1254
1716
  }
1255
- document.getElementById("reloadArtifactsBtn").onclick = loadArtifacts;
1717
+ document.getElementById("reloadArtifactsBtn").onclick = () => loadArtifacts(true);
1256
1718
  document.getElementById("artifactSearch").oninput = renderArtifacts;
1257
1719
  document.getElementById("artifactKind").onchange = renderArtifacts;
1258
1720
  document.getElementById("deleteSelectedArtifactsBtn").onclick = () => safe(async () => {
@@ -1368,18 +1830,22 @@
1368
1830
  throw err;
1369
1831
  }
1370
1832
  }
1371
- async function loadTasks() {
1833
+ async function loadTasks(reset = true) {
1834
+ if (reset) jobsPager.reset();
1372
1835
  setLoading("tasksList", "Loading tasks...");
1373
- const [d, jobs] = await Promise.all([api("/api/tasks"), api("/api/jobs")]);
1836
+ const [d, jobs] = await Promise.all([api("/api/tasks"), api("/api/jobs", { query: { limit: 100, cursor: jobsPager.cursor || void 0 } })]);
1374
1837
  renderTasks(d, jobs);
1838
+ jobsPager.render(jobs?.pagination || {});
1375
1839
  }
1376
1840
  function taskCard(t, title) {
1377
1841
  if (!t) return '<div class="item"><strong>' + esc(title) + "</strong><small>Idle</small></div>";
1378
1842
  const tools = (t.tools || []).map((x) => x.name + " x" + x.count).join(", ") || "-";
1379
- return '<div class="item"><strong>' + esc(title + " \xB7 " + t.status) + "</strong><small>" + esc((t.agentLabel || t.agentId || t.source) + " / " + (t.threadId || "-")) + "</small><small>" + esc("Elapsed " + fmtDuration(t.durationMs) + " / current " + (t.currentTool || "-") + " / last " + (t.lastTool || "-")) + "</small><small>" + esc("Tools: " + tools + " / output chars " + (t.outputChars || 0)) + "</small><small>" + esc(t.prompt || t.detail || "") + "</small></div>";
1843
+ return '<div class="item"><strong>' + esc(title + " \xB7 " + t.status) + "</strong><small>" + esc((t.agentLabel || t.agentId || t.source) + " / " + (t.threadId || "-")) + "</small>" + (t.correlationId ? '<small>CID: <button type="button" class="copy-id" data-copy-id="' + attr(t.correlationId) + '">' + esc(t.correlationId) + '</button> <button type="button" class="secondary mini-button" data-trace-id="' + attr(t.correlationId) + '">Trace</button></small>' : "") + "<small>" + esc("Elapsed " + fmtDuration(t.durationMs) + " / current " + (t.currentTool || "-") + " / last " + (t.lastTool || "-")) + "</small><small>" + esc("Tools: " + tools + " / output chars " + (t.outputChars || 0)) + "</small><small>" + esc(t.prompt || t.detail || "") + "</small></div>";
1380
1844
  }
1381
1845
  function renderTasks(d, jobs) {
1382
- document.getElementById("tasksList").innerHTML = '<div class="task-grid">' + taskCard(d.current, "Current web turn") + taskCard(d.external, "External CLI turn") + '</div><h2 class="task-section-title">Unified jobs</h2><div class="list">' + renderUnifiedJobs(jobs?.jobs || []) + '</div><h2 class="task-section-title">Queue</h2><div class="list">' + ((d.queue || []).map((q) => '<div class="item"><strong>' + esc(q.id + " \xB7 " + q.description) + "</strong><small>" + esc(fmtDate(q.createdAt) + " / attempts " + q.attempts) + '</small><div class="row"><button data-q="run" data-id="' + attr(q.id) + '"' + disabledAttr("queue.write") + '>Run</button><button data-q="cancel" data-id="' + attr(q.id) + '" class="danger"' + disabledAttr("queue.write") + ">Cancel</button></div></div>").join("") || '<div class="item">Queue is empty.</div>') + '</div><h2 class="task-section-title">Recent turns</h2><div class="list">' + ((d.recent || []).map((e) => '<div class="item"><strong>' + esc(e.status + " / " + e.source + " / " + e.type) + "</strong><small>" + esc(fmtDate(e.timestamp) + " / " + (e.threadId || "-")) + "</small><small>" + esc(short(e.prompt || e.detail || "", 300)) + "</small></div>").join("") || '<div class="item">No recent tasks.</div>') + "</div>";
1846
+ document.getElementById("tasksList").innerHTML = '<div class="task-grid">' + taskCard(d.current, "Current web turn") + taskCard(d.external, "External CLI turn") + '</div><h2 class="task-section-title">Unified jobs</h2><div class="list">' + renderUnifiedJobs(jobs?.jobs || []) + '</div><h2 class="task-section-title">Queue</h2><div class="list">' + ((d.queue || []).map((q) => '<div class="item"><strong>' + esc(q.id + " \xB7 " + q.description) + "</strong><small>" + esc(fmtDate(q.createdAt) + " / attempts " + q.attempts) + (q.correlationId ? " / CID: " : "") + (q.correlationId ? '<button type="button" class="copy-id" data-copy-id="' + attr(q.correlationId) + '">' + esc(q.correlationId) + '</button> <button type="button" class="secondary mini-button" data-trace-id="' + attr(q.correlationId) + '">Trace</button>' : "") + '</small><div class="row"><button data-q="run" data-id="' + attr(q.id) + '"' + disabledAttr("queue.write") + '>Run</button><button data-q="cancel" data-id="' + attr(q.id) + '" class="danger"' + disabledAttr("queue.write") + ">Cancel</button></div></div>").join("") || '<div class="item">Queue is empty.</div>') + '</div><h2 class="task-section-title">Recent turns</h2><div class="list">' + ((d.recent || []).map((e) => '<div class="item"><strong>' + esc(e.status + " / " + e.source + " / " + e.type) + "</strong><small>" + esc(fmtDate(e.timestamp) + " / " + (e.threadId || "-")) + (e.correlationId ? " / CID: " : "") + (e.correlationId ? '<button type="button" class="copy-id" data-copy-id="' + attr(e.correlationId) + '">' + esc(e.correlationId) + '</button> <button type="button" class="secondary mini-button" data-trace-id="' + attr(e.correlationId) + '">Trace</button>' : "") + "</small><small>" + esc(short(e.prompt || e.detail || "", 300)) + "</small></div>").join("") || '<div class="item">No recent tasks.</div>') + "</div>";
1847
+ document.querySelectorAll("#tasksList [data-copy-id]").forEach((b) => b.onclick = () => copyText(b.dataset.copyId || "", "Correlation ID copied"));
1848
+ document.querySelectorAll("#tasksList [data-trace-id]").forEach((b) => b.onclick = () => openTrace(b.dataset.traceId || ""));
1383
1849
  document.querySelectorAll("#tasksList [data-q]").forEach((b) => b.onclick = () => safe(async () => {
1384
1850
  if (!can("queue.write")) {
1385
1851
  toast("Permission required: queue.write");
@@ -1396,7 +1862,7 @@
1396
1862
  return jobs.map((job) => {
1397
1863
  const retryPermission = jobActionPermission(job, "retry");
1398
1864
  const cancelPermission = jobActionPermission(job, "cancel");
1399
- return '<div class="item"><strong>' + esc(job.title) + ' <span class="adapter-status ' + esc(jobStatusClass(job.status)) + '">' + esc(job.status) + "</span></strong><small>" + esc([job.kind, job.source, job.agentLabel || job.agentId, fmtDate(job.startedAt)].filter(Boolean).join(" / ")) + "</small>" + (job.owner ? "<small>" + esc("Owner: " + (job.owner.label || job.owner.username || job.owner.id || "-")) + "</small>" : "") + (job.threadId ? "<small>" + esc("Thread: " + job.threadId) + "</small>" : "") + (job.summary ? "<small>" + esc(short(job.summary, 300)) + "</small>" : "") + (job.logTail ? '<pre class="update-log">' + esc(short(job.logTail, 1200)) + "</pre>" : "") + '<div class="row">' + (job.canReadLog ? '<button class="secondary" data-job-log="' + attr(job.id) + '">Log</button>' : "") + (job.canRetry ? '<button class="secondary" data-job-action="retry" data-job-permission="' + attr(retryPermission) + '" data-job-id="' + attr(job.id) + '"' + disabledAttr(retryPermission) + ">Retry</button>" : "") + (job.canCancel ? '<button class="danger" data-job-action="cancel" data-job-permission="' + attr(cancelPermission) + '" data-job-id="' + attr(job.id) + '"' + disabledAttr(cancelPermission) + ">Cancel</button>" : "") + "</div></div>";
1865
+ return '<div class="item"><strong>' + esc(job.title) + ' <span class="adapter-status ' + esc(jobStatusClass(job.status)) + '">' + esc(job.status) + "</span></strong><small>" + esc([job.kind, job.source, job.agentLabel || job.agentId, fmtDate(job.startedAt)].filter(Boolean).join(" / ")) + "</small>" + (job.correlationId ? '<small>CID: <button type="button" class="copy-id" data-copy-id="' + attr(job.correlationId) + '">' + esc(job.correlationId) + '</button> <button type="button" class="secondary mini-button" data-trace-id="' + attr(job.correlationId) + '">Trace</button></small>' : "") + (job.owner ? "<small>" + esc("Owner: " + (job.owner.label || job.owner.username || job.owner.id || "-")) + "</small>" : "") + (job.threadId ? "<small>" + esc("Thread: " + job.threadId) + "</small>" : "") + (job.summary ? "<small>" + esc(short(job.summary, 300)) + "</small>" : "") + (job.logTail ? '<pre class="update-log">' + esc(short(job.logTail, 1200)) + "</pre>" : "") + '<div class="row">' + (job.canReadLog ? '<button class="secondary" data-job-log="' + attr(job.id) + '">Log</button>' : "") + (job.canRetry ? '<button class="secondary" data-job-action="retry" data-job-permission="' + attr(retryPermission) + '" data-job-id="' + attr(job.id) + '"' + disabledAttr(retryPermission) + ">Retry</button>" : "") + (job.canCancel ? '<button class="danger" data-job-action="cancel" data-job-permission="' + attr(cancelPermission) + '" data-job-id="' + attr(job.id) + '"' + disabledAttr(cancelPermission) + ">Cancel</button>" : "") + "</div></div>";
1400
1866
  }).join("") || '<div class="item">No jobs.</div>';
1401
1867
  }
1402
1868
  function jobActionPermission(job, action) {
@@ -1424,7 +1890,7 @@
1424
1890
  }
1425
1891
  }));
1426
1892
  }
1427
- document.getElementById("reloadTasksBtn").onclick = () => loadTasks();
1893
+ document.getElementById("reloadTasksBtn").onclick = () => loadTasks(true);
1428
1894
  async function loadMetrics() {
1429
1895
  setLoading("metricsPanel", "Loading metrics...");
1430
1896
  const d = await api("/api/metrics");
@@ -1488,20 +1954,28 @@
1488
1954
  ["Buckets", (rate?.buckets || []).length]
1489
1955
  ].map(([k, v]) => [name + " " + k, v]);
1490
1956
  }
1957
+ function webRouteRows(d) {
1958
+ return (d.web?.routes || []).slice(0, 8).map((route) => [route.method + " " + route.path, route.averageMs + "ms avg / " + route.maxMs + "ms max / " + route.count + " hit(s)"]);
1959
+ }
1960
+ function webSlowRows(d) {
1961
+ return (d.web?.slowest || []).slice(0, 8).map((sample) => [sample.method + " " + sample.path, sample.durationMs + "ms / " + sample.statusCode + " / " + fmtDate(sample.at)]);
1962
+ }
1491
1963
  function renderMetrics(d) {
1492
1964
  const adapters = d.adapters || {};
1493
1965
  const adapterCards = Object.entries(adapters).map(([name, rate]) => card(name.charAt(0).toUpperCase() + name.slice(1) + " rate limits", rateRows("", rate).map(([k, v]) => [String(k).trim(), v]))).join("");
1494
- document.getElementById("metricsPanel").innerHTML = '<div class="metrics-grid">' + card("Runtime", metricStatusRows(d)) + card("Process", metricProcessRows(d)) + card("Jobs", metricJobRows(d)) + adapterCards + "</div>";
1966
+ document.getElementById("metricsPanel").innerHTML = '<div class="metrics-grid">' + card("Runtime", metricStatusRows(d)) + card("Process", metricProcessRows(d)) + card("Jobs", metricJobRows(d)) + card("Web API latency", webRouteRows(d)) + card("Slow Web API calls", webSlowRows(d)) + adapterCards + "</div>";
1495
1967
  }
1496
1968
  document.getElementById("reloadMetricsBtn").onclick = () => safe(loadMetrics);
1497
1969
  function activityQuery() {
1498
- return { source: val("activitySource"), category: val("activityCategory"), status: val("activityStatus"), limit: val("activityLimit") || "100", actor: val("activityActor") || void 0, agent: val("activityAgent") || "all", thread: val("activityThread") || void 0, workspace: val("activityWorkspace") || void 0, type: val("activityType") || void 0, since: val("activitySince") || void 0 };
1970
+ return { source: val("activitySource"), category: val("activityCategory"), status: val("activityStatus"), limit: val("activityLimit") || "100", cursor: activityPager.cursor || void 0, actor: val("activityActor") || void 0, agent: val("activityAgent") || "all", thread: val("activityThread") || void 0, workspace: val("activityWorkspace") || void 0, type: val("activityType") || void 0, since: val("activitySince") || void 0 };
1499
1971
  }
1500
- async function loadActivity() {
1972
+ async function loadActivity(reset = true) {
1973
+ if (reset) activityPager.reset();
1501
1974
  setLoading("activityList", "Loading activity...");
1502
1975
  const data = await api("/api/activity", { query: activityQuery() });
1503
1976
  state.activityEvents = data.events || [];
1504
1977
  renderActivity(state.activityEvents);
1978
+ activityPager.render(data.pagination || {});
1505
1979
  }
1506
1980
  function activityWorkspace(e) {
1507
1981
  const active = state.snapshot?.session;
@@ -1517,6 +1991,7 @@
1517
1991
  const actor = activityActorText(e);
1518
1992
  const parts = [];
1519
1993
  if (actor) parts.push("User: " + esc(actor));
1994
+ if (e.correlationId) parts.push('CID: <button type="button" class="copy-id" data-copy-id="' + attr(e.correlationId) + '">' + esc(e.correlationId) + '</button> <button type="button" class="secondary mini-button" data-trace-id="' + attr(e.correlationId) + '">Trace</button>');
1520
1995
  if (e.threadId) parts.push('<button type="button" class="copy-id" data-copy-id="' + attr(e.threadId) + '">' + esc(e.threadId) + "</button>");
1521
1996
  if (workspace) parts.push(esc(workspace));
1522
1997
  if (duration) parts.push(esc(duration));
@@ -1528,9 +2003,10 @@
1528
2003
  return '<div class="item"><strong><span class="chip ' + (e.status === "failed" ? "error" : e.status === "queued" ? "warn" : "") + '">' + esc(e.status) + "</span>" + esc([fmtDate(e.timestamp), e.source, e.category, e.type].filter(Boolean).join(" | ")) + "</strong><small>" + esc(short(e.prompt || e.detail || "", 220)) + "</small>" + (meta ? "<small>" + meta + "</small>" : "") + "</div>";
1529
2004
  }).join("") || '<div class="item">No activity.</div>';
1530
2005
  document.querySelectorAll("#activityList [data-copy-id]").forEach((b) => b.onclick = () => copyText(b.dataset.copyId || "", "Thread ID copied"));
2006
+ document.querySelectorAll("#activityList [data-trace-id]").forEach((b) => b.onclick = () => openTrace(b.dataset.traceId || ""));
1531
2007
  }
1532
- document.getElementById("loadActivityBtn").onclick = () => loadActivity();
1533
- document.getElementById("activitySince").onchange = () => loadActivity();
2008
+ document.getElementById("loadActivityBtn").onclick = () => loadActivity(true);
2009
+ document.getElementById("activitySince").onchange = () => loadActivity(true);
1534
2010
  document.getElementById("exportActivityBtn").onclick = () => {
1535
2011
  const rows = (state.activityEvents || []).map((e) => [e.timestamp, e.source, e.category || "", e.status, e.type, activityActorText(e), e.agentId || "", e.threadId || "", activityWorkspace(e), e.prompt || e.detail || ""].join("\\t")).join("\\n");
1536
2012
  const blob = new Blob([rows], { type: "text/tab-separated-values" });
@@ -1540,23 +2016,63 @@
1540
2016
  a.click();
1541
2017
  URL.revokeObjectURL(a.href);
1542
2018
  };
2019
+ function renderTracePlaceholder() {
2020
+ const target = document.getElementById("traceDetail");
2021
+ if (target && !target.innerHTML) target.innerHTML = '<div class="item">Enter a correlation ID or open Trace from Activity/Tasks.</div>';
2022
+ }
2023
+ async function openTrace(correlationId) {
2024
+ document.getElementById("traceCorrelationId").value = correlationId;
2025
+ page("trace");
2026
+ await loadTrace(correlationId);
2027
+ }
2028
+ async function loadTrace(correlationId = val("traceCorrelationId")) {
2029
+ if (!correlationId) {
2030
+ renderTracePlaceholder();
2031
+ return;
2032
+ }
2033
+ setLoading("traceDetail", "Loading trace...");
2034
+ const data = await api("/api/trace", { query: { correlationId } });
2035
+ renderTrace(data);
2036
+ }
2037
+ function renderTrace(data) {
2038
+ const s = data.summary || {};
2039
+ const rows = [["Correlation ID", data.correlationId], ["Status", s.status], ["Started", fmtDate(s.startedAt)], ["Updated", fmtDate(s.updatedAt)], ["Agent", s.agentId], ["Thread", s.threadId], ["Workspace", s.workspace], ["Sources", (s.sources || []).join(", ")]];
2040
+ const timeline = (data.timeline || []).map((item) => '<div class="item"><strong>' + esc(fmtDate(item.at) + " | " + item.source + " | " + (item.status || item.type)) + "</strong><small>" + esc([item.title, item.agentId, item.threadId, item.workspace].filter(Boolean).join(" | ")) + "</small>" + (item.detail ? "<small>" + esc(short(item.detail, 500)) + "</small>" : "") + "</div>").join("") || '<div class="item">No events for this correlation ID.</div>';
2041
+ document.getElementById("traceDetail").innerHTML = card("Trace summary", rows) + '<h2 class="task-section-title">Timeline</h2>' + timeline;
2042
+ }
2043
+ document.getElementById("loadTraceBtn").onclick = () => loadTrace();
2044
+ document.getElementById("traceCorrelationId").addEventListener("keydown", (e) => {
2045
+ if (e.key === "Enter") loadTrace();
2046
+ });
1543
2047
  async function loadSettings() {
1544
2048
  state.settingsWizard = null;
1545
- document.getElementById("settingsTabs").style.display = "";
2049
+ document.getElementById("settingsTabHeader").style.display = "";
2050
+ document.getElementById("settingsSubnav").style.display = "";
2051
+ document.getElementById("settingsActions").style.display = "";
1546
2052
  setLoading("settingsForm", "Loading settings...");
1547
2053
  const data = await api("/api/settings");
1548
2054
  state.settings = data.settings;
1549
2055
  renderSettings();
1550
2056
  }
1551
- const settingsGroupOrder = ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code", "Telegram", "Discord", "Slack", "Operations", "Artifacts", "Workspace", "Peers", "Voice", "Dashboard"];
1552
- const agentSettingGroups = ["Codex", "Pi", "Hermes", "OpenClaw", "Claude Code"];
1553
- function orderedSettingsGroups(groups) {
1554
- const known = settingsGroupOrder.filter((name) => groups[name]);
1555
- const extra = Object.keys(groups).filter((name) => !settingsGroupOrder.includes(name)).sort();
1556
- return known.concat(extra);
1557
- }
1558
- function agentSettingsNav(current) {
1559
- return '<div class="agent-settings-nav"><strong>Agent settings</strong>' + agentSettingGroups.map((name) => '<button type="button" data-setting-tab="' + attr(name) + '" class="' + (name === current ? "active" : "") + '">' + esc(name) + "</button>").join("") + "</div>";
2057
+ const settingsCategoryDefinitions = [
2058
+ { id: "agents", label: "Agents", groups: ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code"] },
2059
+ { id: "chat", label: "Chat", groups: ["Telegram", "Discord", "Slack"] },
2060
+ { id: "operations", label: "Operations", groups: ["Operations", "Artifacts", "Peers", "Voice"] },
2061
+ { id: "workspace", label: "Workspace", groups: ["Workspace"] },
2062
+ { id: "dashboard", label: "Dashboard", groups: ["Dashboard"] }
2063
+ ];
2064
+ function settingsCategories(groups) {
2065
+ const used = /* @__PURE__ */ new Set();
2066
+ const categories = settingsCategoryDefinitions.map((def) => {
2067
+ const available = def.groups.filter((name) => groups[name]);
2068
+ available.forEach((name) => used.add(name));
2069
+ return available.length ? { id: def.id, label: def.label, groups: available, count: available.reduce((sum, name) => sum + groups[name].length, 0) } : null;
2070
+ }).filter(Boolean);
2071
+ Object.keys(groups).filter((name) => !used.has(name)).sort().forEach((name) => categories.push({ id: "extra:" + name, label: name, groups: [name], count: groups[name].length }));
2072
+ return categories;
2073
+ }
2074
+ function settingsCategoryForGroup(categories, group) {
2075
+ return categories.find((category) => category.groups.includes(group)) || categories[0];
1560
2076
  }
1561
2077
  function settingHelp(s) {
1562
2078
  return s.help ? '<span class="setting-info" tabindex="0" role="img" aria-label="' + attr(s.help) + '" title="' + attr(s.help) + '">i</span>' : "";
@@ -1564,23 +2080,44 @@
1564
2080
  function settingLabel(s) {
1565
2081
  return '<label class="setting-label"><span>' + esc(s.label) + "</span>" + settingHelp(s) + "</label>";
1566
2082
  }
2083
+ function settingCategoryButton(category, activeCategory) {
2084
+ const active = category.id === activeCategory?.id;
2085
+ return '<button type="button" role="tab" aria-selected="' + (active ? "true" : "false") + '" tabindex="' + (active ? "0" : "-1") + '" data-setting-category="' + attr(category.id) + '" class="' + (active ? "active" : "") + '">' + esc(category.label + " (" + category.count + ")") + "</button>";
2086
+ }
2087
+ function renderSettingsSubnav(category, groups) {
2088
+ const target = document.getElementById("settingsSubnav");
2089
+ if (!target) return;
2090
+ if (!category || category.groups.length < 2) {
2091
+ target.hidden = true;
2092
+ target.innerHTML = "";
2093
+ return;
2094
+ }
2095
+ target.hidden = false;
2096
+ target.innerHTML = "<label><span>" + esc(category.label) + ' section</span><select id="settingsSubgroupSelect">' + category.groups.map((name) => '<option value="' + attr(name) + '" ' + (name === state.settingsGroup ? "selected" : "") + ">" + esc(name + " (" + groups[name].length + ")") + "</option>").join("") + "</select></label>";
2097
+ document.getElementById("settingsSubgroupSelect").onchange = (e) => {
2098
+ state.settingsGroup = e.target.value;
2099
+ renderSettings();
2100
+ };
2101
+ }
2102
+ function bindSettingsTabs(categories) {
2103
+ document.querySelectorAll("#settingsTabs [data-setting-category]").forEach((b) => b.onclick = () => {
2104
+ const category = categories.find((item) => item.id === b.dataset.settingCategory);
2105
+ if (!category) return;
2106
+ if (!category.groups.includes(state.settingsGroup)) state.settingsGroup = category.groups[0];
2107
+ renderSettings();
2108
+ });
2109
+ }
1567
2110
  function renderSettings() {
1568
2111
  const groups = {};
1569
2112
  state.settings.forEach((s) => (groups[s.group] ??= []).push(s));
1570
- const names = orderedSettingsGroups(groups);
1571
- if (!state.settingsGroup || !groups[state.settingsGroup]) state.settingsGroup = groups.Agents ? "Agents" : names[0];
1572
- document.getElementById("settingsTabs").innerHTML = names.map((name) => '<button data-setting-tab="' + attr(name) + '" class="' + (name === state.settingsGroup ? "active" : "") + '">' + esc(name) + " (" + groups[name].length + ")</button>").join("");
1573
- document.querySelectorAll("[data-setting-tab]").forEach((b) => b.onclick = () => {
1574
- state.settingsGroup = b.dataset.settingTab;
1575
- renderSettings();
1576
- });
2113
+ const categories = settingsCategories(groups);
2114
+ if (!state.settingsGroup || !groups[state.settingsGroup]) state.settingsGroup = groups.Agents ? "Agents" : categories[0]?.groups[0];
2115
+ const activeCategory = settingsCategoryForGroup(categories, state.settingsGroup);
2116
+ document.getElementById("settingsTabs").innerHTML = categories.map((category) => settingCategoryButton(category, activeCategory)).join("");
2117
+ renderSettingsSubnav(activeCategory, groups);
1577
2118
  const items = groups[state.settingsGroup] || [];
1578
- const nav = state.settingsGroup === "Agents" || agentSettingGroups.includes(state.settingsGroup) ? agentSettingsNav(state.settingsGroup) : "";
1579
- document.getElementById("settingsForm").innerHTML = '<div class="settings-section"><h2>' + esc(state.settingsGroup || "Settings") + '</h2><div id="settingsRestartBanner"></div>' + nav + items.map((s) => '<div class="setting" data-setting-box="' + attr(s.key) + '" data-restart-required="' + (s.restartRequired ? "true" : "false") + '">' + settingLabel(s) + settingInput(s) + "<small>" + esc(s.key) + " - " + esc(s.description) + (s.effectiveValue ? " Active: " + esc(s.effectiveValue) + "." : "") + (s.restartRequired ? " Restart required." : "") + (s.configured ? " Saved in env file." : " Using default.") + '</small><div class="setting-actions"><button type="button" class="secondary" data-reset-setting="' + attr(s.key) + '">Use default</button>' + (s.kind === "secret" ? '<button type="button" class="secondary" data-reveal-setting="' + attr(s.key) + '">Reveal/replace</button>' : "") + '</div><div class="setting-error"></div></div>').join("") + "</div>";
1580
- document.querySelectorAll("[data-setting-tab]").forEach((b) => b.onclick = () => {
1581
- state.settingsGroup = b.dataset.settingTab;
1582
- renderSettings();
1583
- });
2119
+ document.getElementById("settingsForm").innerHTML = '<div class="settings-section"><h2>' + esc(state.settingsGroup || "Settings") + '</h2><div id="settingsRestartBanner"></div>' + items.map((s) => '<div class="setting" data-setting-box="' + attr(s.key) + '" data-restart-required="' + (s.restartRequired ? "true" : "false") + '">' + settingLabel(s) + settingInput(s) + "<small>" + esc(s.key) + " - " + esc(s.description) + (s.effectiveValue ? " Active: " + esc(s.effectiveValue) + "." : "") + (s.restartRequired ? " Restart required." : "") + (s.configured ? " Saved in env file." : " Using default.") + '</small><div class="setting-actions"><button type="button" class="secondary" data-reset-setting="' + attr(s.key) + '">Use default</button>' + (s.kind === "secret" ? '<button type="button" class="secondary" data-reveal-setting="' + attr(s.key) + '">Reveal/replace</button>' : "") + '</div><div class="setting-error"></div></div>').join("") + "</div>";
2120
+ bindSettingsTabs(categories);
1584
2121
  bindSettingsUx();
1585
2122
  }
1586
2123
  function settingAttrs(s, original) {
@@ -2060,9 +2597,10 @@
2060
2597
  loadLocks();
2061
2598
  });
2062
2599
  function auditQuery() {
2063
- return { limit: val("auditLimit") || "50", channel: val("auditChannel") || "all", category: val("auditCategory") || "all", status: val("auditStatus") || "all", actor: val("auditActor") || void 0, agent: val("auditAgent") || "all", thread: val("auditThread") || void 0, workspace: val("auditWorkspace") || void 0, since: val("auditSince") || void 0 };
2600
+ return { limit: val("auditLimit") || "50", cursor: auditPager.cursor || void 0, channel: val("auditChannel") || "all", category: val("auditCategory") || "all", status: val("auditStatus") || "all", actor: val("auditActor") || void 0, agent: val("auditAgent") || "all", thread: val("auditThread") || void 0, workspace: val("auditWorkspace") || void 0, since: val("auditSince") || void 0 };
2064
2601
  }
2065
- async function loadAudit() {
2602
+ async function loadAudit(reset = true) {
2603
+ if (reset) auditPager.reset();
2066
2604
  if (!can("audit.read")) {
2067
2605
  document.getElementById("auditList").innerHTML = '<div class="item">Permission required: audit.read</div>';
2068
2606
  return;
@@ -2070,6 +2608,7 @@
2070
2608
  const d = await api("/api/audit", { query: auditQuery() });
2071
2609
  state.auditEvents = d.events || [];
2072
2610
  renderAudit(state.auditEvents);
2611
+ auditPager.render(d.pagination || {});
2073
2612
  }
2074
2613
  function renderAudit(events) {
2075
2614
  document.getElementById("auditList").innerHTML = (events || []).map((e) => {
@@ -2078,7 +2617,7 @@
2078
2617
  return '<div class="item"><strong>' + esc([fmtDate(e.timestamp), e.channelId, e.status, e.category, e.action].filter(Boolean).join(" | ")) + "</strong><small>" + esc("Actor: " + actor) + "</small>" + (meta ? "<small>" + esc(meta) + "</small>" : "") + "<small>" + esc(e.description || e.detail || "") + "</small></div>";
2079
2618
  }).join("") || '<div class="item">No audit events.</div>';
2080
2619
  }
2081
- document.getElementById("loadAuditBtn").onclick = () => loadAudit();
2620
+ document.getElementById("loadAuditBtn").onclick = () => loadAudit(true);
2082
2621
  document.getElementById("exportAuditBtn").onclick = () => {
2083
2622
  const events = state.auditEvents || [];
2084
2623
  const text = events.map((e) => [fmtDate(e.timestamp), e.channelId, e.status, e.category, e.action, e.actor?.label || e.actor?.username || e.actor?.id || e.actorId || "system", e.contextKey, e.agentId || "", e.threadId || "", e.workspace || "", e.description || e.detail || ""].join("\\t")).join("\\n");
@@ -2099,22 +2638,32 @@
2099
2638
  if (document.getElementById("logFollow").checked) document.getElementById("logs").scrollTop = document.getElementById("logs").scrollHeight;
2100
2639
  }
2101
2640
  document.getElementById("loadLogsBtn").onclick = loadLogs;
2102
- function logLevelOf(line) {
2103
- if (line.includes(" ERROR ")) return "ERROR";
2104
- if (line.includes(" WARN ")) return "WARN";
2105
- if (line.includes(" INFO ")) return "INFO";
2106
- return "";
2641
+ function explicitLogLevelOf(line) {
2642
+ const m = String(line || "").match(/^\s*(?:\[[^\]]+\]|\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\s+[+-]\d{2}:?\d{2})?)?\s*(ERROR|WARN|INFO)\b/);
2643
+ return m ? m[1] : "";
2107
2644
  }
2108
2645
  function logTimeOf(line) {
2109
- const m = line.match(/^(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})/);
2646
+ const text = String(line || "");
2647
+ const m = text.match(/^\s*\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})(?:\s+[+-]\d{2}:?\d{2})?\]?/);
2110
2648
  return m ? new Date(m[1].replace(" ", "T")).getTime() : 0;
2111
2649
  }
2650
+ function parsedLogLines() {
2651
+ let currentLevel = "INFO";
2652
+ let currentTime = 0;
2653
+ return state.logsPlain.split(/\n/).filter((line) => line.length > 0).map((line) => {
2654
+ const explicit = explicitLogLevelOf(line);
2655
+ if (explicit) currentLevel = explicit;
2656
+ const time = logTimeOf(line);
2657
+ if (time) currentTime = time;
2658
+ return { line, level: currentLevel, time: currentTime };
2659
+ });
2660
+ }
2112
2661
  function renderLogs() {
2113
2662
  const level = val("logLevel");
2114
2663
  const query = val("logSearch").toLowerCase();
2115
2664
  const since = val("logSince") ? new Date(val("logSince")).getTime() : 0;
2116
- const lines = state.logsPlain.split(/\\n/).filter((line) => line.length > 0 && (level === "all" || line.includes(level)) && (!query || line.toLowerCase().includes(query)) && (!since || !logTimeOf(line) || logTimeOf(line) >= since));
2117
- document.getElementById("logs").innerHTML = lines.map((line) => '<span class="log-line ' + logLevelOf(line) + '">' + esc(line) + "</span>").join("") || "(empty)";
2665
+ const lines = parsedLogLines().filter((entry) => (level === "all" || entry.level === level) && (!query || entry.line.toLowerCase().includes(query)) && (!since || !entry.time || entry.time >= since));
2666
+ document.getElementById("logs").innerHTML = lines.map((entry) => '<span class="log-line ' + entry.level + '">' + esc(entry.line) + "</span>").join("") || "(empty)";
2118
2667
  }
2119
2668
  document.getElementById("logLevel").onchange = renderLogs;
2120
2669
  document.getElementById("logSearch").oninput = renderLogs;
@@ -2151,14 +2700,17 @@
2151
2700
  return;
2152
2701
  }
2153
2702
  setLoading("peersList", "Loading peers...");
2154
- const d = await api("/api/peers", { local: true });
2703
+ const [d, jobsData] = await Promise.all([api("/api/peers", { local: true }), can("peers.connect") ? api("/api/peers/discovery-jobs", { local: true }).catch(() => ({ jobs: [] })) : Promise.resolve({ jobs: [] })]);
2155
2704
  state.peers = d;
2705
+ state.peerDiscoveryJobs = jobsData.jobs || [];
2156
2706
  const inviteIds = new Set((d.invitations || []).map((i) => i.id));
2157
2707
  Object.keys(state.peerInviteSecrets || {}).forEach((id) => {
2158
2708
  if (!inviteIds.has(id)) delete state.peerInviteSecrets[id];
2159
2709
  });
2160
2710
  document.getElementById("peerStatus").innerHTML = peerStatusHtml(d);
2161
2711
  document.getElementById("peersList").innerHTML = (d.peers || []).map(peerCard).join("") || '<div class="item">No peers configured.</div>';
2712
+ document.getElementById("peerDiscovery").innerHTML = peerDiscoveryJobsHtml(state.peerDiscoveryJobs);
2713
+ bindUiCopyButtons(document.getElementById("peerDiscovery"));
2162
2714
  document.getElementById("peerInvites").innerHTML = (d.invitations || []).map(peerInviteCard).join("") || '<div class="item">No open invitations.</div>';
2163
2715
  ensureGlobalPeerSessionsPanel();
2164
2716
  bindPeerButtons();
@@ -2167,8 +2719,9 @@
2167
2719
  }
2168
2720
  function openPeerAddDialog() {
2169
2721
  const publicUrl = state.peers?.enabled ? state.peers?.listenUrl || "" : "";
2170
- adminDialog("Add peer", '<label>Peer URL<input id="dlgPeerAddUrl" placeholder="https://host:31979"></label><label>Pairing code<input id="dlgPeerAddCode"></label><label>Name<input id="dlgPeerAddName" placeholder="optional local label"></label><label>Public URL for this node<input id="dlgPeerAddPublicUrl" placeholder="optional" value="' + attr(publicUrl) + '"></label>', async () => {
2722
+ adminDialog("Add peer", '<label>Peer URL<input id="dlgPeerAddUrl" placeholder="https://host:31979"></label><label>Pairing code<input id="dlgPeerAddCode"></label><label>Name<input id="dlgPeerAddName" placeholder="optional local label"></label><label>Group<input id="dlgPeerAddGroup" placeholder="LAN, Servers, Workstations"></label><label>Public URL for this node<input id="dlgPeerAddPublicUrl" placeholder="optional" value="' + attr(publicUrl) + '"></label>', async () => {
2171
2723
  const r = await api("/api/peers/pair", { method: "POST", body: JSON.stringify({ url: val("dlgPeerAddUrl"), code: val("dlgPeerAddCode"), name: val("dlgPeerAddName") || void 0, publicUrl: val("dlgPeerAddPublicUrl") || void 0 }), local: true });
2724
+ if (val("dlgPeerAddGroup") && r.peer?.id) await api("/api/peers/" + encodeURIComponent(r.peer.id), { method: "PATCH", body: JSON.stringify({ group: val("dlgPeerAddGroup") }), local: true });
2172
2725
  toast("Added peer " + (r.peer?.name || ""));
2173
2726
  await loadPeers();
2174
2727
  });
@@ -2188,6 +2741,10 @@
2188
2741
  }
2189
2742
  openPeerAddDialog();
2190
2743
  };
2744
+ document.getElementById("discoverPeersBtn").onclick = () => safe(discoverPeers);
2745
+ document.getElementById("cancelPeerDiscoveryBtn").onclick = () => safe(cancelPeerDiscovery);
2746
+ document.getElementById("exportPeerIdentityBtn").onclick = () => safe(exportPeerIdentity);
2747
+ document.getElementById("restorePeerIdentityBtn").onclick = () => safe(restorePeerIdentity);
2191
2748
  async function loadAdapterHealth() {
2192
2749
  setLoading("adapterHealth", "Loading adapters...");
2193
2750
  setLoading("adapterConformance", "Loading conformance...");
@@ -2350,6 +2907,37 @@
2350
2907
  }));
2351
2908
  }
2352
2909
  document.getElementById("loadVersionBtn").onclick = () => loadVersion();
2910
+ document.addEventListener("click", (e) => {
2911
+ const b = e.target.closest?.("[data-peer-repin]");
2912
+ if (!b) return;
2913
+ safe(async () => {
2914
+ if (!can("peers.write")) {
2915
+ toast("Permission required: peers.write");
2916
+ return;
2917
+ }
2918
+ if (confirm("Re-pin TLS fingerprint for this peer?")) {
2919
+ const r = await api("/api/peers/" + encodeURIComponent(b.dataset.peerRepin) + "/repin", { method: "POST", local: true });
2920
+ toast("TLS fingerprint updated: " + (r.peer?.tlsFingerprint || "-"), { duration: 8e3 });
2921
+ loadPeers();
2922
+ }
2923
+ });
2924
+ });
2925
+ document.addEventListener("click", (e) => {
2926
+ const b = e.target.closest?.("[data-peer-rotate]");
2927
+ if (!b) return;
2928
+ safe(async () => {
2929
+ if (!can("peers.write")) {
2930
+ toast("Permission required: peers.write");
2931
+ return;
2932
+ }
2933
+ if (confirm("Create a new pairing invite for this peer using the current scopes?")) {
2934
+ const r = await api("/api/peers/" + encodeURIComponent(b.dataset.peerRotate) + "/rotate", { method: "POST", body: JSON.stringify({ expiresMinutes: 10 }), local: true });
2935
+ if (r.invitation?.id) state.peerInviteSecrets[r.invitation.id] = { code: r.code || "", command: r.command || "" };
2936
+ toast("Rotation invite created. Pairing details are shown under Open invitations.", { duration: 8e3 });
2937
+ loadPeers();
2938
+ }
2939
+ });
2940
+ });
2353
2941
  document.getElementById("updateBtn").onclick = () => safe(async () => {
2354
2942
  if (!can("updates.run")) {
2355
2943
  toast("Permission required: updates.run");
@@ -2429,6 +3017,92 @@
2429
3017
  const command = r.manualCheckCommand || "nordrelay peer check " + (d.listenUrl || "");
2430
3018
  return '<div class="item"><strong>Local peer identity <span class="adapter-status ' + (r.enabled && r.localListening ? "enabled" : r.enabled ? "planned" : "disabled") + '">' + esc(r.enabled ? r.localListening ? "ready" : "not listening" : "disabled") + "</span></strong>" + rows.map((row) => "<small>" + esc(row[0]) + ": " + esc(row[1] ?? "-") + "</small>").join("") + peerWarningsHtml(r.warnings || [], "Peer readiness") + '<div class="peer-invite-details"><small>Manual reachability check</small><button type="button" class="copy-id peer-invite-command" data-peer-invite-copy="' + attr(command) + '" data-peer-invite-copy-label="Peer check command copied">' + esc(command) + '</button><small>Run this on another machine to verify LAN, port-forward, or firewall reachability.</small></div><div class="row"><button id="checkPeerReachabilityBtn" class="secondary"' + disabledAttr("peers.connect") + '>Check local endpoint</button></div><div id="peerProbeResult">' + peerProbeResultHtml(state.peerProbeResult) + "</div></div>";
2431
3019
  }
3020
+ async function discoverPeers() {
3021
+ if (!can("peers.connect")) {
3022
+ toast("Permission required: peers.connect");
3023
+ return;
3024
+ }
3025
+ const target = document.getElementById("peerDiscovery");
3026
+ target.innerHTML = loadingHtml("Starting LAN peer discovery...");
3027
+ const body = { targets: csvToList(val("peerDiscoveryTargets")), maxHosts: Number(val("peerDiscoveryMaxHosts") || 512), concurrency: Number(val("peerDiscoveryConcurrency") || 32) };
3028
+ const data = await api("/api/peers/discovery-jobs", { method: "POST", body: JSON.stringify(body), local: true });
3029
+ state.activePeerDiscoveryJobId = data.job?.id;
3030
+ await pollPeerDiscoveryJob(data.job?.id);
3031
+ }
3032
+ async function pollPeerDiscoveryJob(id) {
3033
+ if (!id) return;
3034
+ const target = document.getElementById("peerDiscovery");
3035
+ for (; ; ) {
3036
+ const data = await api("/api/peers/discovery-jobs/" + encodeURIComponent(id), { local: true });
3037
+ const job = data.job;
3038
+ if (!job) {
3039
+ target.innerHTML = uiEmpty("Discovery job not found.");
3040
+ return;
3041
+ }
3042
+ target.innerHTML = peerDiscoveryJobHtml(job);
3043
+ bindUiCopyButtons(target);
3044
+ applyPermissions();
3045
+ if (!["queued", "running"].includes(job.status)) break;
3046
+ await new Promise((r) => setTimeout(r, 1e3));
3047
+ }
3048
+ }
3049
+ async function cancelPeerDiscovery() {
3050
+ const id = state.activePeerDiscoveryJobId;
3051
+ if (!id) {
3052
+ toast("No active discovery job");
3053
+ return;
3054
+ }
3055
+ const data = await api("/api/peers/discovery-jobs/" + encodeURIComponent(id) + "/cancel", { method: "POST", local: true });
3056
+ if (data.job) {
3057
+ document.getElementById("peerDiscovery").innerHTML = peerDiscoveryJobHtml(data.job);
3058
+ }
3059
+ toast("Discovery cancelled");
3060
+ }
3061
+ async function exportPeerIdentity() {
3062
+ if (!can("peers.write")) {
3063
+ toast("Permission required: peers.write");
3064
+ return;
3065
+ }
3066
+ const data = await api("/api/peers/identity/backup", { local: true });
3067
+ downloadJson("nordrelay-peer-identity-backup.json", data.backup);
3068
+ toast("Peer identity backup exported");
3069
+ }
3070
+ async function restorePeerIdentity() {
3071
+ if (!can("peers.write")) {
3072
+ toast("Permission required: peers.write");
3073
+ return;
3074
+ }
3075
+ const text = prompt("Paste peer identity backup JSON");
3076
+ if (!text) return;
3077
+ const backup = JSON.parse(text);
3078
+ await api("/api/peers/identity/restore", { method: "POST", body: JSON.stringify({ backup }), local: true });
3079
+ toast("Peer identity restored. Restart peer server to use it.");
3080
+ await loadPeers();
3081
+ }
3082
+ function downloadJson(name, value) {
3083
+ const blob = new Blob([JSON.stringify(value, null, 2) + "\n"], { type: "application/json" });
3084
+ const a = document.createElement("a");
3085
+ a.href = URL.createObjectURL(blob);
3086
+ a.download = name;
3087
+ a.click();
3088
+ URL.revokeObjectURL(a.href);
3089
+ }
3090
+ function peerDiscoveryJobsHtml(jobs) {
3091
+ const list = (jobs || []).slice(0, 5);
3092
+ return list.map(peerDiscoveryJobHtml).join("") || uiEmpty("No LAN discovery jobs yet.");
3093
+ }
3094
+ function peerDiscoveryJobHtml(job) {
3095
+ const progress = job.total ? Math.round(job.scanned / job.total * 100) : 0;
3096
+ return uiItem("Discovery job " + job.id, { badge: { text: job.status, status: job.status === "completed" ? "enabled" : job.status === "failed" ? "disabled" : "planned" }, rows: [["Progress", job.scanned + " / " + job.total + " (" + progress + "%)"], ["Targets", (job.options?.targets || []).join(", ") || "local LAN + mDNS"], ["Started", fmtDate(job.startedAt)], ["Completed", fmtDate(job.completedAt)]], body: peerDiscoveryHtml(job) + '<details class="peer-health-history"><summary>Discovery log (' + (job.log || []).length + ")</summary>" + (job.log || []).slice(-30).map((line) => "<small>" + esc(line) + "</small>").join("") + "</details>" });
3097
+ }
3098
+ function peerDiscoveryHtml(data) {
3099
+ const warnings = (data.warnings || []).length ? peerWarningsHtml(data.warnings, "Discovery warning") : "";
3100
+ const cards = (data.candidates || []).map((c) => {
3101
+ const command = "nordrelay peer add " + c.url + " --code <pairing-code>";
3102
+ return uiItem(c.name || c.host, { badge: { text: "found", status: "enabled" }, rows: [["URL", c.url], ["Node", c.nodeId], ["Fingerprint", c.fingerprint], ["TLS", c.tlsFingerprint || "-"], ["Latency", c.latencyMs !== void 0 ? c.latencyMs + "ms" : "-"]], body: '<div class="peer-invite-details"><small>Pairing command template</small>' + uiCopyButton(command, "Peer add command copied", "copy-id peer-invite-command") + "</div>" });
3103
+ }).join("");
3104
+ return warnings + (cards || uiEmpty("No LAN peers found. Scanned " + (data.scanned || 0) + " endpoint candidates."));
3105
+ }
2432
3106
  function peerWarningsHtml(warnings, title) {
2433
3107
  return (warnings || []).length ? '<div class="peer-warning full-span"><strong>' + esc(title || "Warning") + "</strong>" + warnings.map((w) => "<small>" + esc(w) + "</small>").join("") + "</div>" : "";
2434
3108
  }
@@ -2446,18 +3120,22 @@
2446
3120
  function peerInviteCard(i) {
2447
3121
  const open = new Date(i.expiresAt) > /* @__PURE__ */ new Date() && !i.usedAt;
2448
3122
  const readiness = state.peers?.readiness;
2449
- return '<div class="item"><strong>' + esc(i.name) + ' <span class="chip">' + esc(open ? "open" : "closed") + "</span></strong><small>" + esc("Expires: " + fmtDate(i.expiresAt)) + "</small><small>" + esc("Scopes: " + (i.scopes || []).join(", ")) + "</small><small>" + esc("Agents: " + ((i.allowedAgents || []).join(", ") || "all")) + "</small>" + (i.usedAt ? "<small>" + esc("Used: " + fmtDate(i.usedAt) + " by " + (i.usedByNodeId || "-")) + "</small>" : "") + (open ? peerWarningsHtml(readiness?.warnings || [], "Pairing warning") + peerInviteDetails(i) + '<div class="row"><button class="danger" data-peer-invite-delete="' + attr(i.id) + '"' + disabledAttr("peers.write") + ">Delete invite</button></div>" : "") + "</div>";
3123
+ return '<div class="item"><strong>' + esc(i.name) + ' <span class="chip">' + esc(open ? "open" : "closed") + "</span></strong>" + (i.group ? "<small>" + esc("Group: " + i.group) + "</small>" : "") + "<small>" + esc("Expires: " + fmtDate(i.expiresAt)) + "</small><small>" + esc("Scopes: " + (i.scopes || []).join(", ")) + "</small><small>" + esc("Agents: " + ((i.allowedAgents || []).join(", ") || "all")) + "</small>" + (i.usedAt ? "<small>" + esc("Used: " + fmtDate(i.usedAt) + " by " + (i.usedByNodeId || "-")) + "</small>" : "") + (open ? peerWarningsHtml(readiness?.warnings || [], "Pairing warning") + peerInviteDetails(i) + '<div class="row"><button class="danger" data-peer-invite-delete="' + attr(i.id) + '"' + disabledAttr("peers.write") + ">Delete invite</button></div>" : "") + "</div>";
2450
3124
  }
2451
3125
  function peerCard(p) {
2452
3126
  const selected = state.selectedPeer === p.id ? ' <span class="chip">selected</span>' : "";
3127
+ const trust = p.trustStatus || "trusted";
3128
+ const trustClass = trust === "trusted" ? "enabled" : trust === "tls-unpinned" ? "planned" : "disabled";
2453
3129
  const health = p.remoteStatus || p.lastSeenAt ? "Health: " + (p.remoteStatus || "seen") + (p.lastLatencyMs !== void 0 ? " / " + p.lastLatencyMs + "ms" : "") + (p.remoteVersion ? " / v" + p.remoteVersion : "") : "Health: unchecked";
2454
3130
  const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
2455
- return '<div class="item"><strong>' + esc(p.name) + ' <span class="adapter-status ' + (p.enabled ? "enabled" : "disabled") + '">' + (p.enabled ? "enabled" : "disabled") + "</span>" + selected + "</strong><small>" + esc("URL: " + (p.url || "-")) + "</small><small>" + esc("Node: " + p.nodeId + " / " + p.fingerprint) + "</small>" + (p.tlsFingerprint ? "<small>" + esc("TLS: " + p.tlsFingerprint) + "</small>" : "") + "<small>" + esc("Direction: " + p.direction + " / scopes " + (p.scopes || []).join(", ")) + "</small><small>" + esc("Agents: " + ((p.allowedAgents || []).join(", ") || "all")) + "</small><small>" + esc("Workspaces: " + ((p.allowedWorkspaceRoots || []).join(", ") || "all")) + "</small>" + (aliases ? "<small>" + esc("Aliases: " + aliases) + "</small>" : "") + "<small>" + esc(health) + "</small>" + (p.lastCheckedAt ? "<small>" + esc("Checked: " + fmtDate(p.lastCheckedAt)) + "</small>" : "") + (p.lastSeenAt ? "<small>" + esc("Last seen: " + fmtDate(p.lastSeenAt)) + "</small>" : "") + (p.lastError ? '<small class="error">' + esc("Last error: " + p.lastError) + "</small>" : "") + '<div class="row"><button data-peer-select="' + attr(p.id) + '">Use target</button><button class="secondary" data-peer-test="' + attr(p.id) + '">Test</button><button class="secondary" data-peer-probe="' + attr(p.id) + '"' + disabledAttr("peers.connect") + '>Probe this node</button><button class="secondary" data-peer-edit="' + attr(p.id) + '"' + disabledAttr("peers.write") + '>Edit</button><button class="secondary" data-peer-toggle="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">" + (p.enabled ? "Disable" : "Enable") + '</button><button class="danger" data-peer-revoke="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">Revoke</button></div></div>";
3131
+ const effective = "Effective access: " + (p.scopes || []).length + " scope(s), agents " + ((p.allowedAgents || []).join(", ") || "all") + ", workspaces " + ((p.allowedWorkspaceRoots || []).join(", ") || "all");
3132
+ const history = (p.healthHistory || []).slice(-5).reverse().map((h) => "<small>" + esc(fmtDate(h.checkedAt) + " | " + h.status + (h.latencyMs !== void 0 ? " | " + h.latencyMs + "ms" : "") + (h.error ? " | " + h.error : "")) + "</small>").join("");
3133
+ return '<div class="item"><strong>' + esc(p.name) + ' <span class="adapter-status ' + (p.enabled ? "enabled" : "disabled") + '">' + (p.enabled ? "enabled" : "disabled") + '</span> <span class="adapter-status ' + trustClass + '">' + esc(trust) + "</span>" + selected + "</strong>" + (p.group ? "<small>" + esc("Group: " + p.group) + "</small>" : "") + "<small>" + esc("URL: " + (p.url || "-")) + "</small><small>" + esc("Node: " + p.nodeId + " / " + p.fingerprint) + "</small>" + (p.tlsFingerprint ? "<small>" + esc("TLS: " + p.tlsFingerprint) + "</small>" : "") + (p.trustWarnings && p.trustWarnings.length ? peerWarningsHtml(p.trustWarnings, "Trust warning") : "") + "<small>" + esc("Direction: " + p.direction + " / scopes " + (p.scopes || []).join(", ")) + "</small><small>" + esc("Agents: " + ((p.allowedAgents || []).join(", ") || "all")) + "</small><small>" + esc("Workspaces: " + ((p.allowedWorkspaceRoots || []).join(", ") || "all")) + "</small>" + (aliases ? "<small>" + esc("Aliases: " + aliases) + "</small>" : "") + "<small>" + esc(effective) + "</small><small>" + esc(health) + "</small>" + (p.lastCheckedAt ? "<small>" + esc("Checked: " + fmtDate(p.lastCheckedAt)) + "</small>" : "") + (p.lastSeenAt ? "<small>" + esc("Last seen: " + fmtDate(p.lastSeenAt)) + "</small>" : "") + (p.lastError ? '<small class="error">' + esc("Last error: " + p.lastError) + "</small>" : "") + (history ? '<details class="peer-health-history"><summary>Health history (' + (p.healthHistory || []).length + ")</summary>" + history + "</details>" : "") + '<div class="row"><button data-peer-select="' + attr(p.id) + '">Use target</button><button class="secondary" data-peer-test="' + attr(p.id) + '">Test</button><button class="secondary" data-peer-probe="' + attr(p.id) + '"' + disabledAttr("peers.connect") + '>Probe this node</button><button class="secondary" data-peer-repin="' + attr(p.id) + '"' + disabledAttr("peers.write") + '>Trust TLS</button><button class="secondary" data-peer-rotate="' + attr(p.id) + '"' + disabledAttr("peers.write") + '>Rotate</button><button class="secondary" data-peer-edit="' + attr(p.id) + '"' + disabledAttr("peers.write") + '>Edit</button><button class="secondary" data-peer-toggle="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">" + (p.enabled ? "Disable" : "Enable") + '</button><button class="danger" data-peer-revoke="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">Revoke</button></div></div>";
2456
3134
  }
2457
3135
  function openPeerDialog(p) {
2458
3136
  const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
2459
- adminDialog("Edit peer", '<label>Name<input id="dlgPeerName" value="' + attr(p.name || "") + '"></label><label>URL<input id="dlgPeerUrl" value="' + attr(p.url || "") + '"></label><label class="checkbox"><input id="dlgPeerEnabled" type="checkbox" ' + (p.enabled ? "checked" : "") + '> Enabled</label><label class="full-span">Scopes<input id="dlgPeerScopes" value="' + attr((p.scopes || []).join(", ")) + '"></label><label class="full-span">Allowed agents<input id="dlgPeerAgents" value="' + attr((p.allowedAgents || []).join(", ")) + '"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerWorkspaces" value="' + attr((p.allowedWorkspaceRoots || []).join(", ")) + '"></label><label class="full-span">Workspace aliases<input id="dlgPeerAliases" placeholder="project=/srv/project, demo=/home/me/demo" value="' + attr(aliases) + '"></label>', async () => {
2460
- await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ name: val("dlgPeerName"), url: val("dlgPeerUrl"), enabled: document.getElementById("dlgPeerEnabled").checked, scopes: csvToList(val("dlgPeerScopes")), allowedAgents: csvToList(val("dlgPeerAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerAliases")) }), local: true });
3137
+ adminDialog("Edit peer", '<label>Name<input id="dlgPeerName" value="' + attr(p.name || "") + '"></label><label>Group<input id="dlgPeerGroup" value="' + attr(p.group || "") + '" placeholder="LAN, Servers, Workstations"></label><label>URL<input id="dlgPeerUrl" value="' + attr(p.url || "") + '"></label><label class="checkbox"><input id="dlgPeerEnabled" type="checkbox" ' + (p.enabled ? "checked" : "") + '> Enabled</label><label class="full-span">Scopes<input id="dlgPeerScopes" value="' + attr((p.scopes || []).join(", ")) + '"></label><label class="full-span">Allowed agents<input id="dlgPeerAgents" value="' + attr((p.allowedAgents || []).join(", ")) + '"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerWorkspaces" value="' + attr((p.allowedWorkspaceRoots || []).join(", ")) + '"></label><label class="full-span">Workspace aliases<input id="dlgPeerAliases" placeholder="project=/srv/project, demo=/home/me/demo" value="' + attr(aliases) + '"></label>', async () => {
3138
+ await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ name: val("dlgPeerName"), group: val("dlgPeerGroup"), url: val("dlgPeerUrl"), enabled: document.getElementById("dlgPeerEnabled").checked, scopes: csvToList(val("dlgPeerScopes")), allowedAgents: csvToList(val("dlgPeerAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerAliases")) }), local: true });
2461
3139
  toast("Peer updated");
2462
3140
  await loadPeers();
2463
3141
  });
@@ -2465,8 +3143,8 @@
2465
3143
  function openPeerInviteDialog() {
2466
3144
  const warnings = state.peers?.readiness?.warnings || [];
2467
3145
  const warningHtml = peerWarningsHtml(warnings, "Pairing warning") + (warnings.length ? '<small class="full-span">The invite can still be created, but pairing may fail until the peer endpoint is reachable.</small>' : "");
2468
- adminDialog("Create peer invite", warningHtml + '<label>Name<input id="dlgPeerInviteName" value="NordRelay peer"></label><label>Expires minutes<input id="dlgPeerInviteExpires" type="number" value="10" min="1" max="1440"></label><label class="full-span">Scopes<input id="dlgPeerInviteScopes" value="inspect, sessions.read, sessions.write, prompt.send, prompt.abort, queue.read, queue.write, files.read, files.write, diagnostics.read, logs.read"></label><label class="full-span">Allowed agents<input id="dlgPeerInviteAgents" value="codex, pi, hermes, openclaw, claude-code"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerInviteWorkspaces" placeholder="empty means all"></label><label class="full-span">Workspace aliases<input id="dlgPeerInviteAliases" placeholder="project=/srv/project, demo=/home/me/demo"></label>', async () => {
2469
- const r = await api("/api/peers/invite", { method: "POST", body: JSON.stringify({ name: val("dlgPeerInviteName"), expiresMinutes: Number(val("dlgPeerInviteExpires") || 10), scopes: csvToList(val("dlgPeerInviteScopes")), allowedAgents: csvToList(val("dlgPeerInviteAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerInviteWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerInviteAliases")) }), local: true });
3146
+ adminDialog("Create peer invite", warningHtml + '<label>Name<input id="dlgPeerInviteName" value="NordRelay peer"></label><label>Group<input id="dlgPeerInviteGroup" placeholder="LAN, Servers, Workstations"></label><label>Expires minutes<input id="dlgPeerInviteExpires" type="number" value="10" min="1" max="1440"></label><label class="full-span">Scopes<input id="dlgPeerInviteScopes" value="inspect, sessions.read, sessions.write, prompt.send, prompt.abort, queue.read, queue.write, files.read, files.write, diagnostics.read, logs.read"></label><label class="full-span">Allowed agents<input id="dlgPeerInviteAgents" value="codex, pi, hermes, openclaw, claude-code"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerInviteWorkspaces" placeholder="empty means all"></label><label class="full-span">Workspace aliases<input id="dlgPeerInviteAliases" placeholder="project=/srv/project, demo=/home/me/demo"></label>', async () => {
3147
+ const r = await api("/api/peers/invite", { method: "POST", body: JSON.stringify({ name: val("dlgPeerInviteName"), group: val("dlgPeerInviteGroup") || void 0, expiresMinutes: Number(val("dlgPeerInviteExpires") || 10), scopes: csvToList(val("dlgPeerInviteScopes")), allowedAgents: csvToList(val("dlgPeerInviteAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerInviteWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerInviteAliases")) }), local: true });
2470
3148
  if (r.invitation?.id) state.peerInviteSecrets[r.invitation.id] = { code: r.code || "", command: r.command || "" };
2471
3149
  toast("Peer invite created. Pairing details are shown under Open invitations.", { duration: 8e3 });
2472
3150
  await loadPeers();
@@ -2542,6 +3220,459 @@
2542
3220
  }
2543
3221
  }));
2544
3222
  }
3223
+ const ACCESS_USER_PAGE_SIZE = 20;
3224
+ const ACCESS_PERMISSION_GROUPS = [
3225
+ ["Overview", ["inspect"]],
3226
+ ["Sessions", ["sessions.read", "sessions.write", "prompt.send", "prompt.abort", "queue.read", "queue.write"]],
3227
+ ["Files", ["files.read", "files.write"]],
3228
+ ["Operations", ["settings.read", "settings.write", "auth.manage", "diagnostics.read", "logs.read", "logs.clear", "updates.run", "system.restart"]],
3229
+ ["Users and audit", ["users.read", "users.write", "audit.read"]],
3230
+ ["Peers", ["peers.read", "peers.write", "peers.connect"]]
3231
+ ];
3232
+ function ensureAccessUiState() {
3233
+ if (!state.userFilters) state.userFilters = { query: "", status: "all", group: "all", identity: "all" };
3234
+ if (!state.userPage) state.userPage = 1;
3235
+ if (!state.userPageSize) state.userPageSize = ACCESS_USER_PAGE_SIZE;
3236
+ if (!state.userDetailAudit) state.userDetailAudit = {};
3237
+ }
3238
+ function groupIdsForUser(u) {
3239
+ return (u.groups || []).map((g) => g.id);
3240
+ }
3241
+ function groupNamesForUser(u) {
3242
+ return (u.groups || []).map((g) => g.name).join(", ") || "-";
3243
+ }
3244
+ function hasUserIdentity(u, kind) {
3245
+ if (kind === "telegram") return (u.telegramIdentities || []).length > 0;
3246
+ if (kind === "discord") return (u.discordIdentities || []).length > 0;
3247
+ if (kind === "slack") return (u.slackIdentities || []).length > 0;
3248
+ if (kind === "web") return (u.webSessions || []).length > 0;
3249
+ if (kind === "unlinked") return !hasUserIdentity(u, "telegram") && !hasUserIdentity(u, "discord") && !hasUserIdentity(u, "slack");
3250
+ return true;
3251
+ }
3252
+ function userSearchText(u) {
3253
+ return [u.displayName, u.email, u.id, groupNamesForUser(u), (u.telegramIdentities || []).map((i) => [i.telegramUserId, i.username].join(" ")).join(" "), (u.discordIdentities || []).map((i) => [i.discordUserId, i.username, i.globalName].join(" ")).join(" "), (u.slackIdentities || []).map((i) => [i.slackUserId, i.teamId, i.username, i.realName].join(" ")).join(" ")].join(" ").toLowerCase();
3254
+ }
3255
+ function filteredUsers() {
3256
+ ensureAccessUiState();
3257
+ const filters = state.userFilters;
3258
+ const query = (filters.query || "").toLowerCase().trim();
3259
+ return (state.userManagement?.users || []).filter((u) => {
3260
+ if (filters.status === "active" && !u.active) return false;
3261
+ if (filters.status === "disabled" && u.active) return false;
3262
+ if (filters.group && filters.group !== "all" && !groupIdsForUser(u).includes(filters.group)) return false;
3263
+ if (filters.identity && filters.identity !== "all" && !hasUserIdentity(u, filters.identity)) return false;
3264
+ if (query && !userSearchText(u).includes(query)) return false;
3265
+ return true;
3266
+ });
3267
+ }
3268
+ function renderUserGroupFilter() {
3269
+ const select = document.getElementById("userGroupFilter");
3270
+ if (!select) return;
3271
+ const current = state.userFilters?.group || "all";
3272
+ select.innerHTML = '<option value="all">All groups</option>' + (state.userManagement?.groups || []).map((g) => '<option value="' + attr(g.id) + '">' + esc(g.name) + "</option>").join("");
3273
+ select.value = current;
3274
+ }
3275
+ function bindAccessFilters() {
3276
+ ensureAccessUiState();
3277
+ const search = document.getElementById("userSearch");
3278
+ if (search && !search.dataset.bound) {
3279
+ search.dataset.bound = "true";
3280
+ search.oninput = () => {
3281
+ state.userFilters.query = search.value;
3282
+ state.userPage = 1;
3283
+ renderUsersList();
3284
+ };
3285
+ }
3286
+ if (search) search.value = state.userFilters.query || "";
3287
+ const status = document.getElementById("userStatusFilter");
3288
+ if (status && !status.dataset.bound) {
3289
+ status.dataset.bound = "true";
3290
+ status.onchange = () => {
3291
+ state.userFilters.status = status.value;
3292
+ state.userPage = 1;
3293
+ renderUsersList();
3294
+ };
3295
+ }
3296
+ if (status) status.value = state.userFilters.status || "all";
3297
+ const group = document.getElementById("userGroupFilter");
3298
+ if (group && !group.dataset.bound) {
3299
+ group.dataset.bound = "true";
3300
+ group.onchange = () => {
3301
+ state.userFilters.group = group.value;
3302
+ state.userPage = 1;
3303
+ renderUsersList();
3304
+ };
3305
+ }
3306
+ if (group) group.value = state.userFilters.group || "all";
3307
+ const identity = document.getElementById("userIdentityFilter");
3308
+ if (identity && !identity.dataset.bound) {
3309
+ identity.dataset.bound = "true";
3310
+ identity.onchange = () => {
3311
+ state.userFilters.identity = identity.value;
3312
+ state.userPage = 1;
3313
+ renderUsersList();
3314
+ };
3315
+ }
3316
+ if (identity) identity.value = state.userFilters.identity || "all";
3317
+ const groupSearch = document.getElementById("groupSearch");
3318
+ if (groupSearch && !groupSearch.dataset.bound) {
3319
+ groupSearch.dataset.bound = "true";
3320
+ groupSearch.oninput = () => renderGroupsList();
3321
+ }
3322
+ const telegramSearch = document.getElementById("telegramChatSearch");
3323
+ if (telegramSearch && !telegramSearch.dataset.bound) {
3324
+ telegramSearch.dataset.bound = "true";
3325
+ telegramSearch.oninput = () => renderTelegramChats();
3326
+ }
3327
+ }
3328
+ function switchAccessTabV2(tab) {
3329
+ state.accessTab = tab || "users";
3330
+ document.querySelectorAll("[data-access-tab]").forEach((b) => {
3331
+ const active = b.dataset.accessTab === state.accessTab;
3332
+ b.classList.toggle("active", active);
3333
+ b.setAttribute("aria-selected", active ? "true" : "false");
3334
+ b.tabIndex = active ? 0 : -1;
3335
+ });
3336
+ document.querySelectorAll("[data-access-tab-panel]").forEach((panel) => panel.classList.toggle("active", panel.dataset.accessTabPanel === state.accessTab));
3337
+ bindAccessFilters();
3338
+ }
3339
+ function bindAccessTabsV2() {
3340
+ document.querySelectorAll("[data-access-tab]").forEach((b) => b.onclick = () => switchAccessTabV2(b.dataset.accessTab));
3341
+ bindAccessFilters();
3342
+ const discordSearch = document.getElementById("discordChannelSearch");
3343
+ if (discordSearch && !discordSearch.dataset.bound) {
3344
+ discordSearch.dataset.bound = "true";
3345
+ discordSearch.oninput = () => renderDiscordChannelsV2();
3346
+ }
3347
+ const slackSearch = document.getElementById("slackChannelSearch");
3348
+ if (slackSearch && !slackSearch.dataset.bound) {
3349
+ slackSearch.dataset.bound = "true";
3350
+ slackSearch.oninput = () => renderSlackChannelsV2();
3351
+ }
3352
+ }
3353
+ function renderUserManagementV2(d) {
3354
+ ensureAccessUiState();
3355
+ state.userManagement = d;
3356
+ renderUserGroupFilter();
3357
+ bindAccessFilters();
3358
+ renderUsersList();
3359
+ renderGroupsList();
3360
+ renderTelegramChats();
3361
+ renderDiscordChannelsV2(d.discordChannels || []);
3362
+ renderSlackChannelsV2(d.slackChannels || []);
3363
+ bindUserButtonsV2();
3364
+ bindSlackUserButtons();
3365
+ bindAccessCopyButtons();
3366
+ applyPermissions();
3367
+ }
3368
+ function userIdentityChips(u) {
3369
+ const chips = [];
3370
+ const telegram = (u.telegramIdentities || []).length;
3371
+ const discord = (u.discordIdentities || []).length;
3372
+ const slack = (u.slackIdentities || []).length;
3373
+ const web = (u.webSessions || []).length;
3374
+ if (telegram) chips.push('<span class="chip">Telegram ' + telegram + "</span>");
3375
+ if (discord) chips.push('<span class="chip">Discord ' + discord + "</span>");
3376
+ if (slack) chips.push('<span class="chip">Slack ' + slack + "</span>");
3377
+ if (web) chips.push('<span class="chip">Web ' + web + "</span>");
3378
+ return chips.join("") || '<span class="chip">No chat identity</span>';
3379
+ }
3380
+ function effectivePermissions(u) {
3381
+ return Array.from(new Set((u.groups || []).flatMap((g) => g.permissions || []))).sort();
3382
+ }
3383
+ function scopedUnion(u, key) {
3384
+ const groups = u.groups || [];
3385
+ if (groups.some((g) => (g[key] || []).length === 0)) return ["all"];
3386
+ return Array.from(new Set(groups.flatMap((g) => g[key] || []))).sort();
3387
+ }
3388
+ function userCard(u) {
3389
+ const groups = (u.groups || []).map((g) => '<span class="chip">' + esc(g.name) + "</span>").join("") || '<span class="chip">No groups</span>';
3390
+ const perms = effectivePermissions(u);
3391
+ return '<div class="item user-card"><div class="user-card-main"><div><strong>' + esc(u.displayName) + ' <span class="adapter-status ' + (u.active ? "enabled" : "disabled") + '">' + (u.active ? "active" : "disabled") + "</span></strong><small>" + esc(u.email) + '</small><small class="access-id-row">User ID: ' + accessCopyButton(u.id, "User ID copied") + '</small></div><div class="user-card-meta">' + groups + userIdentityChips(u) + "</div></div><small>" + esc(perms.length + " permissions \xB7 agents " + scopedUnion(u, "agentIds").join(", ") + " \xB7 workspaces " + scopedUnion(u, "workspaceRoots").join(", ")) + '</small><div class="row"><button data-user-detail="' + attr(u.id) + '">Details</button><button class="secondary" data-user-edit="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-user-toggle="' + attr(u.id) + '"' + disabledAttr("users.write") + ">" + (u.active ? "Disable" : "Enable") + "</button></div></div>";
3392
+ }
3393
+ function renderUsersList() {
3394
+ const target = document.getElementById("accessPanel");
3395
+ if (!target) return;
3396
+ const users = filteredUsers();
3397
+ const pages = Math.max(1, Math.ceil(users.length / (state.userPageSize || ACCESS_USER_PAGE_SIZE)));
3398
+ if (state.userPage > pages) state.userPage = pages;
3399
+ const start = (state.userPage - 1) * (state.userPageSize || ACCESS_USER_PAGE_SIZE);
3400
+ const pageUsers = users.slice(start, start + (state.userPageSize || ACCESS_USER_PAGE_SIZE));
3401
+ target.innerHTML = pageUsers.map(userCard).join("") || '<div class="item">No users match the current filters.</div>';
3402
+ renderUsersPager(users.length, pages);
3403
+ bindUserButtonsV2();
3404
+ bindAccessCopyButtons();
3405
+ applyPermissions();
3406
+ }
3407
+ function renderUsersPager(total, pages) {
3408
+ const pager = document.getElementById("usersPager");
3409
+ if (!pager) return;
3410
+ const start = total ? (state.userPage - 1) * (state.userPageSize || ACCESS_USER_PAGE_SIZE) + 1 : 0;
3411
+ const end = Math.min(total, state.userPage * (state.userPageSize || ACCESS_USER_PAGE_SIZE));
3412
+ pager.innerHTML = "<span>" + esc(start + "-" + end + " of " + total + " users \xB7 page " + state.userPage + " of " + pages) + '</span><div class="pager-actions"><button data-user-page="prev" ' + (state.userPage <= 1 ? "disabled" : "") + '>Previous</button><button data-user-page="next" ' + (state.userPage >= pages ? "disabled" : "") + ">Next</button></div>";
3413
+ pager.querySelector('[data-user-page="prev"]').onclick = () => {
3414
+ if (state.userPage > 1) {
3415
+ state.userPage -= 1;
3416
+ renderUsersList();
3417
+ }
3418
+ };
3419
+ pager.querySelector('[data-user-page="next"]').onclick = () => {
3420
+ if (state.userPage < pages) {
3421
+ state.userPage += 1;
3422
+ renderUsersList();
3423
+ }
3424
+ };
3425
+ }
3426
+ function renderGroupsList() {
3427
+ const target = document.getElementById("groupsList");
3428
+ if (!target) return;
3429
+ const query = (document.getElementById("groupSearch")?.value || "").toLowerCase();
3430
+ const groups = (state.userManagement?.groups || []).filter((g) => !query || [g.name, g.id, g.description, (g.permissions || []).join(" ")].join(" ").toLowerCase().includes(query));
3431
+ target.innerHTML = groups.map((g) => {
3432
+ const users = (state.userManagement?.users || []).filter((u) => groupIdsForUser(u).includes(g.id)).length;
3433
+ return '<div class="item group-card"><strong>' + esc(g.name) + " " + (g.system ? '<span class="chip">system</span>' : "") + "</strong><small>" + esc(g.description || "") + "</small><small>" + esc(users + " user(s) \xB7 " + (g.permissions || []).length + " permission(s)") + "</small><small>Agent scope: " + esc(csv(g.agentIds) || "all") + "</small><small>Workspace scope: " + esc(csv(g.workspaceRoots) || "all") + "</small><small>Channels: " + esc(["Telegram " + (csv(g.telegramChatIds) || "all"), "Discord " + (discordScopeLabel(g.discordChannelIds) || "all"), "Slack " + (slackScopeLabel(g.slackChannelIds) || "all")].join(" \xB7 ")) + '</small><div class="row"><button class="secondary" data-group-edit="' + attr(g.id) + '"' + disabledAttr("users.write") + ">Edit group</button></div></div>";
3434
+ }).join("") || '<div class="item">No groups match the current filters.</div>';
3435
+ bindUserButtonsV2();
3436
+ applyPermissions();
3437
+ }
3438
+ function renderTelegramChats(chats = state.userManagement?.telegramChats || []) {
3439
+ const target = document.getElementById("telegramChatsList");
3440
+ if (!target) return;
3441
+ const query = (document.getElementById("telegramChatSearch")?.value || "").toLowerCase();
3442
+ const filtered = (chats || []).filter((c) => !query || [c.title, c.chatId, c.type, groupNames(c.allowedGroupIds)].filter(Boolean).join(" ").toLowerCase().includes(query));
3443
+ target.innerHTML = filtered.map((c) => '<div class="item"><strong>' + esc(c.title || String(c.chatId)) + ' <span class="adapter-status ' + (c.enabled ? "enabled" : "disabled") + '">' + (c.enabled ? "enabled" : "disabled") + '</span></strong><small class="access-id-row">Chat ID: ' + accessCopyButton(String(c.chatId), "Telegram chat ID copied") + "</small><small>" + esc("Type: " + (c.type || "-")) + "</small><small>Groups: " + esc(groupNames(c.allowedGroupIds) || "all groups") + '</small><div class="row"><button data-chat-edit="' + attr(c.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-chat-toggle="' + attr(c.id) + '"' + disabledAttr("users.write") + ">" + (c.enabled ? "Disable" : "Enable") + "</button></div></div>").join("") || '<div class="item">No Telegram group chats registered.</div>';
3444
+ bindUserButtonsV2();
3445
+ bindAccessCopyButtons();
3446
+ applyPermissions();
3447
+ }
3448
+ function renderDiscordChannelsV2(channels = state.userManagement?.discordChannels || []) {
3449
+ const target = document.getElementById("discordChannelsList");
3450
+ if (!target) return;
3451
+ const query = (document.getElementById("discordChannelSearch")?.value || "").toLowerCase();
3452
+ const filtered = (channels || []).filter((c) => !query || [c.title, c.channelId, c.guildId, c.type, groupNames(c.allowedGroupIds)].filter(Boolean).join(" ").toLowerCase().includes(query));
3453
+ target.innerHTML = filtered.map((c) => '<div class="item"><strong>' + esc(c.title || String(c.channelId)) + ' <span class="adapter-status ' + (c.enabled ? "enabled" : "disabled") + '">' + (c.enabled ? "enabled" : "disabled") + '</span></strong><small class="access-id-row">Channel ID: ' + accessCopyButton(c.channelId, "Discord channel ID copied") + '</small><small class="access-id-row">Guild ID: ' + accessCopyButton(c.guildId || "", "Discord guild ID copied") + "</small><small>" + esc("Type: " + (c.type || "-")) + "</small><small>Groups: " + esc(groupNames(c.allowedGroupIds) || "all groups") + '</small><div class="row"><button data-discord-channel-edit="' + attr(c.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-discord-channel-toggle="' + attr(c.id) + '"' + disabledAttr("users.write") + ">" + (c.enabled ? "Disable" : "Enable") + "</button></div></div>").join("") || '<div class="item">No Discord channels registered.</div>';
3454
+ bindDiscordChannelButtons();
3455
+ bindAccessCopyButtons();
3456
+ applyPermissions();
3457
+ }
3458
+ function renderSlackChannelsV2(channels = state.userManagement?.slackChannels || []) {
3459
+ const target = document.getElementById("slackChannelsList");
3460
+ if (!target) return;
3461
+ const query = (document.getElementById("slackChannelSearch")?.value || "").toLowerCase();
3462
+ const filtered = (channels || []).filter((c) => !query || [c.title, c.channelId, c.teamId, c.type, groupNames(c.allowedGroupIds)].filter(Boolean).join(" ").toLowerCase().includes(query));
3463
+ target.innerHTML = filtered.map((c) => '<div class="item"><strong>' + esc(c.title || String(c.channelId)) + ' <span class="adapter-status ' + (c.enabled ? "enabled" : "disabled") + '">' + (c.enabled ? "enabled" : "disabled") + '</span></strong><small class="access-id-row">Channel ID: ' + accessCopyButton(c.channelId, "Slack channel ID copied") + '</small><small class="access-id-row">Team ID: ' + accessCopyButton(c.teamId || "", "Slack team ID copied") + "</small><small>" + esc("Type: " + (c.type || "-")) + "</small><small>Groups: " + esc(groupNames(c.allowedGroupIds) || "all groups") + '</small><div class="row"><button data-slack-channel-edit="' + attr(c.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-slack-channel-toggle="' + attr(c.id) + '"' + disabledAttr("users.write") + ">" + (c.enabled ? "Disable" : "Enable") + "</button></div></div>").join("") || '<div class="item">No Slack channels registered.</div>';
3464
+ bindSlackChannelButtons();
3465
+ bindAccessCopyButtons();
3466
+ applyPermissions();
3467
+ }
3468
+ function bindUserButtonsV2() {
3469
+ document.querySelectorAll("[data-user-detail]").forEach((b) => b.onclick = () => safe(() => openUserDetail(b.dataset.userDetail)));
3470
+ document.querySelectorAll("[data-user-edit]").forEach((b) => b.onclick = () => {
3471
+ const u = (state.userManagement?.users || []).find((x) => x.id === b.dataset.userEdit);
3472
+ if (u) openUserDialog(u);
3473
+ });
3474
+ document.querySelectorAll("[data-user-toggle]").forEach((b) => b.onclick = () => safe(async () => {
3475
+ const u = (state.userManagement?.users || []).find((x) => x.id === b.dataset.userToggle);
3476
+ if (!u) return;
3477
+ await api("/api/users/" + encodeURIComponent(u.id), { method: "PATCH", body: JSON.stringify({ active: !u.active }) });
3478
+ toast("User updated");
3479
+ loadAccess();
3480
+ }));
3481
+ document.querySelectorAll("[data-user-code]").forEach((b) => b.onclick = () => safe(() => showLinkCodeDialog("telegram", b.dataset.userCode)));
3482
+ document.querySelectorAll("[data-user-link]").forEach((b) => b.onclick = () => openTelegramLinkDialog(b.dataset.userLink));
3483
+ document.querySelectorAll("[data-user-discord-code]").forEach((b) => b.onclick = () => safe(() => showLinkCodeDialog("discord", b.dataset.userDiscordCode)));
3484
+ document.querySelectorAll("[data-user-discord-link]").forEach((b) => b.onclick = () => openDiscordLinkDialog(b.dataset.userDiscordLink));
3485
+ document.querySelectorAll("[data-user-slack-code]").forEach((b) => b.onclick = () => safe(() => showLinkCodeDialog("slack", b.dataset.userSlackCode)));
3486
+ document.querySelectorAll("[data-user-slack-link]").forEach((b) => b.onclick = () => openSlackLinkDialog(b.dataset.userSlackLink));
3487
+ document.querySelectorAll("[data-user-password]").forEach((b) => b.onclick = () => openPasswordDialog(b.dataset.userPassword));
3488
+ document.querySelectorAll("[data-user-revoke]").forEach((b) => b.onclick = () => safe(async () => {
3489
+ if (confirm("Revoke all web sessions for this user?")) {
3490
+ await api("/api/users/" + encodeURIComponent(b.dataset.userRevoke) + "/sessions", { method: "DELETE" });
3491
+ toast("Sessions revoked");
3492
+ loadAccess();
3493
+ }
3494
+ }));
3495
+ document.querySelectorAll("[data-user-session-revoke]").forEach((b) => b.onclick = () => safe(async () => {
3496
+ if (confirm("Revoke this web session?")) {
3497
+ await api("/api/users/" + encodeURIComponent(b.dataset.user) + "/sessions/" + encodeURIComponent(b.dataset.userSessionRevoke), { method: "DELETE" });
3498
+ toast("Session revoked");
3499
+ await loadAccess();
3500
+ await openUserDetail(b.dataset.user);
3501
+ }
3502
+ }));
3503
+ document.querySelectorAll("[data-telegram-unlink]").forEach((b) => b.onclick = () => safe(async () => {
3504
+ if (confirm("Unlink this Telegram identity?")) {
3505
+ await api("/api/users/" + encodeURIComponent(b.dataset.telegramUser) + "/telegram/" + encodeURIComponent(b.dataset.telegramUnlink), { method: "DELETE" });
3506
+ toast("Telegram unlinked");
3507
+ loadAccess();
3508
+ }
3509
+ }));
3510
+ document.querySelectorAll("[data-discord-unlink]").forEach((b) => b.onclick = () => safe(async () => {
3511
+ if (confirm("Unlink this Discord identity?")) {
3512
+ await api("/api/users/" + encodeURIComponent(b.dataset.discordUser) + "/discord/" + encodeURIComponent(b.dataset.discordUnlink), { method: "DELETE" });
3513
+ toast("Discord unlinked");
3514
+ loadAccess();
3515
+ }
3516
+ }));
3517
+ document.querySelectorAll("[data-slack-unlink]").forEach((b) => b.onclick = () => safe(async () => {
3518
+ if (confirm("Unlink this Slack identity?")) {
3519
+ await api("/api/users/" + encodeURIComponent(b.dataset.slackUser) + "/slack/" + encodeURIComponent(b.dataset.slackUnlink), { method: "DELETE" });
3520
+ toast("Slack unlinked");
3521
+ loadAccess();
3522
+ }
3523
+ }));
3524
+ document.querySelectorAll("[data-group-edit]").forEach((b) => b.onclick = () => {
3525
+ const g = (state.userManagement?.groups || []).find((x) => x.id === b.dataset.groupEdit);
3526
+ if (g) openGroupDialogV2(g);
3527
+ });
3528
+ document.querySelectorAll("[data-chat-edit]").forEach((b) => b.onclick = () => {
3529
+ const c = (state.userManagement?.telegramChats || []).find((x) => x.id === b.dataset.chatEdit);
3530
+ if (c) openChatDialog(c);
3531
+ });
3532
+ document.querySelectorAll("[data-chat-toggle]").forEach((b) => b.onclick = () => safe(async () => {
3533
+ const c = (state.userManagement?.telegramChats || []).find((x) => x.id === b.dataset.chatToggle);
3534
+ if (!c) return;
3535
+ await api("/api/telegram-chats/" + encodeURIComponent(c.id), { method: "PATCH", body: JSON.stringify({ enabled: !c.enabled }) });
3536
+ toast("Chat updated");
3537
+ loadAccess();
3538
+ }));
3539
+ document.querySelectorAll("[data-discord-channel-edit]").forEach((b) => b.onclick = () => {
3540
+ const c = (state.userManagement?.discordChannels || []).find((x) => x.id === b.dataset.discordChannelEdit);
3541
+ if (c) openDiscordChannelDialog(c);
3542
+ });
3543
+ document.querySelectorAll("[data-discord-channel-toggle]").forEach((b) => b.onclick = () => safe(async () => {
3544
+ const c = (state.userManagement?.discordChannels || []).find((x) => x.id === b.dataset.discordChannelToggle);
3545
+ if (!c) return;
3546
+ await api("/api/discord-channels/" + encodeURIComponent(c.id), { method: "PATCH", body: JSON.stringify({ enabled: !c.enabled }) });
3547
+ toast("Discord channel updated");
3548
+ loadAccess();
3549
+ }));
3550
+ bindAccessCopyButtons();
3551
+ applyPermissions();
3552
+ }
3553
+ function userDetailTab(label, id, active) {
3554
+ return '<button type="button" data-user-detail-tab="' + attr(id) + '" class="' + (active ? "active" : "") + '">' + esc(label) + "</button>";
3555
+ }
3556
+ function userDetailPanel(id, active, body) {
3557
+ return '<div class="user-detail-panel ' + (active ? "active" : "") + '" data-user-detail-panel="' + attr(id) + '">' + body + "</div>";
3558
+ }
3559
+ async function openUserDetail(userId) {
3560
+ const user = (state.userManagement?.users || []).find((u) => u.id === userId);
3561
+ if (!user) return;
3562
+ state.activeUserDetailId = userId;
3563
+ renderUserDetail(user);
3564
+ document.getElementById("userDetailDialog").showModal();
3565
+ if (can("audit.read")) {
3566
+ const data = await api("/api/audit", { query: { limit: 100 } });
3567
+ state.userDetailAudit[userId] = (data.events || []).filter((e) => userAuditMatches(user, e)).slice(0, 15);
3568
+ if (state.activeUserDetailId === userId) renderUserDetail(user);
3569
+ }
3570
+ }
3571
+ function userAuditMatches(user, e) {
3572
+ const text = [e.actor?.id, e.actor?.label, e.actor?.username, e.actorId, e.actorRole, e.description, e.detail].join(" ").toLowerCase();
3573
+ const ids = [user.id, user.email, user.displayName].concat((user.telegramIdentities || []).map((i) => String(i.telegramUserId)), (user.discordIdentities || []).map((i) => i.discordUserId), (user.slackIdentities || []).map((i) => i.slackUserId)).filter(Boolean).map((x) => String(x).toLowerCase());
3574
+ return ids.some((id) => text.includes(id));
3575
+ }
3576
+ function renderUserDetail(user) {
3577
+ const audit = state.userDetailAudit?.[user.id];
3578
+ const profile = card("Profile", [["Email", user.email], ["User ID", user.id], ["Status", user.active ? "active" : "disabled"], ["Created", fmtDate(user.createdAt)], ["Updated", fmtDate(user.updatedAt)], ["Last login", fmtDate(user.lastLoginAt)], ["Web sessions", (user.webSessions || []).length]]);
3579
+ const groups = (user.groups || []).map((g) => uiItem(g.name, { badge: g.system ? { text: "system", status: "disabled" } : null, rows: [["Description", g.description], ["Permissions", (g.permissions || []).length], ["Agent scope", csv(g.agentIds) || "all"], ["Workspace scope", csv(g.workspaceRoots) || "all"]] })).join("") || uiEmpty("No groups.");
3580
+ const identities = identityDetailHtml(user);
3581
+ const sessions = (user.webSessions || []).map((s) => '<div class="item"><strong>' + esc("Session " + s.id) + "</strong><small>" + esc("Created: " + fmtDate(s.createdAt)) + "</small><small>" + esc("Last seen: " + fmtDate(s.lastSeenAt)) + "</small><small>" + esc("Expires: " + fmtDate(s.expiresAt)) + '</small><div class="row"><button class="danger" data-user="' + attr(user.id) + '" data-user-session-revoke="' + attr(s.id) + '"' + disabledAttr("users.write") + ">Revoke</button></div></div>").join("") || uiEmpty("No active web sessions.");
3582
+ const effective = effectiveAccessHtml(user);
3583
+ const auditHtml = !can("audit.read") ? uiEmpty("Permission required: audit.read") : audit ? audit.map((e) => '<div class="item"><strong>' + esc([fmtDate(e.timestamp), e.status, e.action].join(" | ")) + "</strong><small>" + esc(e.description || e.detail || "") + "</small></div>").join("") || uiEmpty("No user-related audit events found.") : loadingHtml("Loading user audit...");
3584
+ const tabs = userDetailTab("Profile", "profile", true) + userDetailTab("Groups", "groups", false) + userDetailTab("Identities", "identities", false) + userDetailTab("Web sessions", "sessions", false) + userDetailTab("Effective access", "effective", false) + userDetailTab("Audit", "audit", false);
3585
+ document.getElementById("userDetail").innerHTML = "<h2>" + esc(user.displayName) + "</h2><p>" + esc(user.email) + '</p><div class="tabs user-detail-tabs">' + tabs + "</div>" + userDetailPanel("profile", true, profile + '<div class="row"><button data-user-edit="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Edit user</button><button class="secondary" data-user-password="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Set password</button><button class="secondary" data-user-toggle="' + attr(user.id) + '"' + disabledAttr("users.write") + ">" + (user.active ? "Disable" : "Enable") + "</button></div>") + userDetailPanel("groups", false, '<div class="list">' + groups + "</div>") + userDetailPanel("identities", false, identities) + userDetailPanel("sessions", false, '<div class="list">' + sessions + '</div><div class="row"><button class="danger" data-user-revoke="' + attr(user.id) + '"' + disabledAttr("users.write") + ">Revoke all sessions</button></div>") + userDetailPanel("effective", false, effective) + userDetailPanel("audit", false, '<div class="list">' + auditHtml + "</div>");
3586
+ bindUserDetailTabs();
3587
+ bindUserButtonsV2();
3588
+ bindAccessCopyButtons();
3589
+ }
3590
+ function bindUserDetailTabs() {
3591
+ document.querySelectorAll("[data-user-detail-tab]").forEach((b) => b.onclick = () => {
3592
+ document.querySelectorAll("[data-user-detail-tab]").forEach((x) => x.classList.toggle("active", x === b));
3593
+ document.querySelectorAll("[data-user-detail-panel]").forEach((panel) => panel.classList.toggle("active", panel.dataset.userDetailPanel === b.dataset.userDetailTab));
3594
+ });
3595
+ }
3596
+ function identityDetailHtml(user) {
3597
+ const telegram = (user.telegramIdentities || []).map((t) => '<div class="item"><strong>Telegram <span class="adapter-status ' + (t.active ? "enabled" : "disabled") + '">' + (t.active ? "active" : "disabled") + '</span></strong><small class="access-id-row">User ID: ' + accessCopyButton(String(t.telegramUserId), "Telegram user ID copied") + "</small>" + (t.username ? "<small>" + esc("@" + t.username) + "</small>" : "") + '<div class="row"><button class="secondary" data-telegram-user="' + attr(user.id) + '" data-telegram-unlink="' + attr(t.id) + '"' + disabledAttr("users.write") + ">Unlink</button></div></div>").join("");
3598
+ const discord = (user.discordIdentities || []).map((i) => '<div class="item"><strong>Discord <span class="adapter-status ' + (i.active ? "enabled" : "disabled") + '">' + (i.active ? "active" : "disabled") + '</span></strong><small class="access-id-row">User ID: ' + accessCopyButton(i.discordUserId, "Discord user ID copied") + "</small>" + (i.username ? "<small>" + esc("@" + i.username) + "</small>" : "") + (i.globalName ? "<small>" + esc(i.globalName) + "</small>" : "") + '<div class="row"><button class="secondary" data-discord-user="' + attr(user.id) + '" data-discord-unlink="' + attr(i.id) + '"' + disabledAttr("users.write") + ">Unlink</button></div></div>").join("");
3599
+ const slack = (user.slackIdentities || []).map((i) => '<div class="item"><strong>Slack <span class="adapter-status ' + (i.active ? "enabled" : "disabled") + '">' + (i.active ? "active" : "disabled") + '</span></strong><small class="access-id-row">User ID: ' + accessCopyButton(i.slackUserId, "Slack user ID copied") + "</small>" + (i.teamId ? '<small class="access-id-row">Team ID: ' + accessCopyButton(i.teamId, "Slack team ID copied") + "</small>" : "") + (i.username ? "<small>" + esc("@" + i.username) + "</small>" : "") + (i.realName ? "<small>" + esc(i.realName) + "</small>" : "") + '<div class="row"><button class="secondary" data-slack-user="' + attr(user.id) + '" data-slack-unlink="' + attr(i.id) + '"' + disabledAttr("users.write") + ">Unlink</button></div></div>").join("");
3600
+ return '<div class="row identity-actions"><button class="secondary" data-user-code="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Telegram code</button><button class="secondary" data-user-link="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Link Telegram ID</button><button class="secondary" data-user-discord-code="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Discord code</button><button class="secondary" data-user-discord-link="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Link Discord ID</button><button class="secondary" data-user-slack-code="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Slack code</button><button class="secondary" data-user-slack-link="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Link Slack ID</button></div><div class="list">' + (telegram + discord + slack || uiEmpty("No chat identities linked.")) + "</div>";
3601
+ }
3602
+ function effectiveAccessHtml(user) {
3603
+ const perms = effectivePermissions(user);
3604
+ const permissionRows = ACCESS_PERMISSION_GROUPS.map(([name, items]) => [name, items.filter((p) => perms.includes(p)).join(", ") || "-"]);
3605
+ const channelRows = [["Agents", scopedUnion(user, "agentIds").join(", ")], ["Workspaces", scopedUnion(user, "workspaceRoots").join(", ")], ["Telegram chat scope", scopedUnion(user, "telegramChatIds").join(", ")], ["Discord channel scope", scopedUnion(user, "discordChannelIds").join(", ")], ["Slack channel scope", scopedUnion(user, "slackChannelIds").join(", ")]];
3606
+ return '<div class="access-effective-grid">' + card("Permissions", permissionRows) + card("Scopes", channelRows) + "</div>";
3607
+ }
3608
+ async function showLinkCodeDialog(channel, userId) {
3609
+ const path = "/api/users/" + encodeURIComponent(userId) + "/" + channel;
3610
+ const data = await api(path, { method: "POST", body: JSON.stringify({ createCode: true }) });
3611
+ const linkCode = data.linkCode || {};
3612
+ const label = channel[0].toUpperCase() + channel.slice(1);
3613
+ showAccessInfoDialog(label + " link code", "<p>Send this code with the " + esc(label) + ' bot/app link command for this user. The code expires automatically.</p><div class="peer-invite-details"><small>Link code</small><button type="button" class="copy-id peer-invite-command" data-access-copy="' + attr(linkCode.code || "") + '" data-access-copy-label="' + attr(label + " link code copied") + '">' + esc(linkCode.code || "") + "</button><small>" + esc("Expires: " + fmtDate(linkCode.expiresAt)) + "</small></div>");
3614
+ }
3615
+ function showAccessInfoDialog(title, body) {
3616
+ const dialog = document.getElementById("adminDialog");
3617
+ document.getElementById("adminDialogTitle").textContent = title;
3618
+ document.getElementById("adminDialogBody").innerHTML = body;
3619
+ document.getElementById("adminDialogSubmit").textContent = "Close";
3620
+ document.getElementById("adminDialogCancel").onclick = () => dialog.close();
3621
+ document.getElementById("adminDialogForm").onsubmit = (e) => {
3622
+ e.preventDefault();
3623
+ dialog.close();
3624
+ };
3625
+ bindAccessCopyButtons();
3626
+ dialog.showModal();
3627
+ }
3628
+ function permissionCheckboxes(selected = []) {
3629
+ const selectedSet = new Set(selected);
3630
+ const used = new Set(ACCESS_PERMISSION_GROUPS.flatMap(([, items]) => items));
3631
+ const extra = (state.userManagement?.permissions || []).filter((p) => !used.has(p)).sort();
3632
+ const groups = ACCESS_PERMISSION_GROUPS.concat(extra.length ? [["Other", extra]] : []);
3633
+ return groups.map(([name, items]) => '<fieldset class="permission-section"><legend>' + esc(name) + "</legend>" + (items || []).map((p) => '<label class="checkbox"><input type="checkbox" data-group-permission="' + attr(p) + '" value="' + attr(p) + '" ' + (selectedSet.has(p) ? "checked" : "") + "> " + esc(p) + "</label>").join("") + "</fieldset>").join("");
3634
+ }
3635
+ function checkboxScope(title, items, selected, attrName, emptyText) {
3636
+ const selectedSet = new Set((selected || []).map(String));
3637
+ return '<div class="scope-section full-span"><strong>' + esc(title) + '</strong><small>Leave every box unchecked to allow all.</small><div class="permission-grid">' + ((items || []).map((item) => '<label class="checkbox"><input type="checkbox" ' + attrName + '="' + attr(item.value) + '" value="' + attr(item.value) + '" ' + (selectedSet.has(String(item.value)) ? "checked" : "") + "> " + esc(item.label) + "</label>").join("") || "<small>" + esc(emptyText || "No options available.") + "</small>") + "</div></div>";
3638
+ }
3639
+ function availableAgentScopeItems() {
3640
+ const labels = { codex: "Codex", pi: "Pi", hermes: "Hermes", openclaw: "OpenClaw", "claude-code": "Claude Code" };
3641
+ const ids = Array.from(/* @__PURE__ */ new Set([...state.enabledAgents || [], "codex", "pi", "hermes", "openclaw", "claude-code"]));
3642
+ return ids.map((id) => ({ value: id, label: labels[id] || id }));
3643
+ }
3644
+ function telegramScopeItems() {
3645
+ return (state.userManagement?.telegramChats || []).map((c) => ({ value: String(c.chatId), label: (c.title || c.chatId) + " / " + (c.type || "-") }));
3646
+ }
3647
+ function discordScopeItems() {
3648
+ return (state.userManagement?.discordChannels || []).map((c) => ({ value: c.channelId, label: discordChannelLabel(c) + " / " + (c.guildId || "DM") }));
3649
+ }
3650
+ function slackScopeItems() {
3651
+ return (state.userManagement?.slackChannels || []).map((c) => ({ value: c.channelId, label: slackChannelLabel(c) + " / " + (c.teamId || "team default") }));
3652
+ }
3653
+ function checkedValues(selector) {
3654
+ return Array.from(document.querySelectorAll(selector + ":checked")).map((el) => el.value).filter(Boolean);
3655
+ }
3656
+ function linesToList(text) {
3657
+ return String(text || "").split(/[\n,]/).map((x) => x.trim()).filter(Boolean);
3658
+ }
3659
+ function openGroupDialogV2(g) {
3660
+ const permissions = g?.permissions || ["inspect", "sessions.read"];
3661
+ const body = '<label>Name<input id="dlgGroupName" value="' + attr(g?.name || "") + '" ' + (g?.system ? "disabled" : "") + '></label><label>Description<input id="dlgGroupDescription" value="' + attr(g?.description || "") + '"></label><label class="full-span">Workspace scope<textarea id="dlgWorkspaceRoots" rows="4" placeholder="One workspace root per line. Empty means all.">' + esc((g?.workspaceRoots || []).join("\n")) + "</textarea></label>" + checkboxScope("Agent scope", availableAgentScopeItems(), g?.agentIds || [], "data-scope-agent", "No agents available.") + checkboxScope("Telegram chat scope", telegramScopeItems(), (g?.telegramChatIds || []).map(String), "data-scope-telegram", "No Telegram chats registered.") + checkboxScope("Discord channel scope", discordScopeItems(), g?.discordChannelIds || [], "data-scope-discord", "No Discord channels registered.") + checkboxScope("Slack channel scope", slackScopeItems(), g?.slackChannelIds || [], "data-scope-slack", "No Slack channels registered.") + '<strong class="full-span">Permissions</strong><div class="permission-category-grid full-span">' + permissionCheckboxes(permissions) + "</div>";
3662
+ adminDialog(g ? "Edit group" : "Create group", body, async () => {
3663
+ const payload = { name: val("dlgGroupName"), description: val("dlgGroupDescription"), permissions: checkedValues("[data-group-permission]"), agentIds: checkedValues("[data-scope-agent]"), workspaceRoots: linesToList(val("dlgWorkspaceRoots")), telegramChatIds: checkedValues("[data-scope-telegram]").map(Number).filter(Number.isInteger), discordChannelIds: checkedValues("[data-scope-discord]"), slackChannelIds: checkedValues("[data-scope-slack]") };
3664
+ await api(g ? "/api/groups/" + encodeURIComponent(g.id) : "/api/groups", { method: g ? "PATCH" : "POST", body: JSON.stringify(payload) });
3665
+ toast(g ? "Group updated" : "Group created");
3666
+ });
3667
+ }
3668
+ switchAccessTab = switchAccessTabV2;
3669
+ bindAccessTabs = bindAccessTabsV2;
3670
+ renderUserManagement = renderUserManagementV2;
3671
+ renderDiscordChannels = renderDiscordChannelsV2;
3672
+ renderSlackChannels = renderSlackChannelsV2;
3673
+ bindUserButtons = bindUserButtonsV2;
3674
+ openGroupDialog = openGroupDialogV2;
3675
+ document.getElementById("closeUserDetailBtn").onclick = () => document.getElementById("userDetailDialog").close();
2545
3676
  const SETUP_WIZARDS = {
2546
3677
  telegram: {
2547
3678
  id: "telegram",
@@ -2641,9 +3772,14 @@
2641
3772
  state.settingsWizard = { home: true };
2642
3773
  renderSettingsWizardHome();
2643
3774
  }
3775
+ function setSettingsChromeVisible(visible) {
3776
+ document.getElementById("settingsTabHeader").style.display = visible ? "" : "none";
3777
+ document.getElementById("settingsSubnav").style.display = visible ? "" : "none";
3778
+ document.getElementById("settingsActions").style.display = visible ? "" : "none";
3779
+ }
2644
3780
  function closeSettingsWizard() {
2645
3781
  state.settingsWizard = null;
2646
- document.getElementById("settingsTabs").style.display = "";
3782
+ setSettingsChromeVisible(true);
2647
3783
  renderSettings();
2648
3784
  }
2649
3785
  function wizardRequiredValuePresent(key) {
@@ -2657,7 +3793,7 @@
2657
3793
  return (wizard.required || []).filter((key) => !wizardRequiredValuePresent(key));
2658
3794
  }
2659
3795
  function renderSettingsWizardHome() {
2660
- document.getElementById("settingsTabs").style.display = "none";
3796
+ setSettingsChromeVisible(false);
2661
3797
  const cards = Object.values(SETUP_WIZARDS).map((wizard) => {
2662
3798
  const missing = wizardMissingRequired(wizard);
2663
3799
  const docs = wizardLinkList(wizard.docs);
@@ -2695,7 +3831,7 @@
2695
3831
  return;
2696
3832
  }
2697
3833
  const step = wizard.steps[state.settingsWizard.step] || wizard.steps[0];
2698
- document.getElementById("settingsTabs").style.display = "none";
3834
+ setSettingsChromeVisible(false);
2699
3835
  document.getElementById("settingsForm").innerHTML = '<div class="settings-wizard"><div class="wizard-header"><div><h2>' + esc(wizard.label) + " setup wizard</h2><p>" + esc(wizard.description) + '</p></div><button type="button" class="secondary" id="wizardHomeBtn">Wizard home</button></div>' + renderWizardProgress(wizard) + '<div class="wizard-step"><h3>' + esc(step.title) + "</h3><p>" + esc(step.body) + "</p>" + wizardLinkList(step.links) + '<div id="wizardRestartBanner"></div><div class="settings-grid">' + step.settings.map(renderWizardSetting).join("") + '</div><div id="wizardErrors" class="wizard-errors"></div><div id="wizardTestResult" class="wizard-test-result"></div><div class="wizard-actions"><button type="button" id="wizardPrevBtn" class="secondary">Back</button><button type="button" id="wizardNextBtn">' + esc(state.settingsWizard.step === wizard.steps.length - 1 ? "Review" : "Next") + '</button><button type="button" id="wizardTestBtn" class="secondary">Test setup</button><button type="button" id="wizardSaveBtn">Save wizard settings</button><button type="button" id="wizardSaveRestartBtn" class="secondary"' + disabledAttr("system.restart") + '>Save and restart</button></div><div class="setting-help">After transport is configured, link users and register allowed chats or channels in the Users page.</div></div></div>';
2700
3836
  bindWizardUx();
2701
3837
  renderWizardValidation();