openclacky 1.3.6 → 1.3.7

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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/lib/clacky/agent.rb +8 -1
  4. data/lib/clacky/agent_profile.rb +121 -53
  5. data/lib/clacky/brand_config.rb +309 -6
  6. data/lib/clacky/cli.rb +4 -57
  7. data/lib/clacky/default_extensions/coding/ext.yml +18 -0
  8. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +95 -0
  9. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +254 -0
  10. data/lib/clacky/default_extensions/ext-studio/ext.yml +32 -0
  11. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +1269 -0
  12. data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +71 -0
  13. data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +74 -0
  14. data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +65 -0
  15. data/lib/clacky/default_extensions/general/ext.yml +18 -0
  16. data/lib/clacky/default_extensions/git/ext.yml +12 -0
  17. data/lib/clacky/{default_agents/_panels/git/panel.js → default_extensions/git/panels/git/view.js} +1 -2
  18. data/lib/clacky/default_extensions/meeting/{handler.rb → api/handler.rb} +78 -26
  19. data/lib/clacky/default_extensions/meeting/ext.yml +15 -0
  20. data/lib/clacky/default_extensions/meeting/{meeting.js → panels/meeting/view.js} +266 -89
  21. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +2 -1
  22. data/lib/clacky/default_extensions/time_machine/ext.yml +12 -0
  23. data/lib/clacky/{default_agents/_panels/time_machine/panel.js → default_extensions/time_machine/panels/time_machine/view.js} +58 -47
  24. data/lib/clacky/default_skills/channel-manager/discord_setup.rb +1 -1
  25. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +83 -18
  26. data/lib/clacky/{api_extension.rb → extension/api_extension.rb} +27 -11
  27. data/lib/clacky/extension/api_loader.rb +136 -0
  28. data/lib/clacky/extension/cli_commands.rb +226 -0
  29. data/lib/clacky/{server/api_extension_dispatcher.rb → extension/dispatcher.rb} +22 -9
  30. data/lib/clacky/extension/hook_loader.rb +77 -0
  31. data/lib/clacky/extension/loader.rb +483 -0
  32. data/lib/clacky/extension/packager.rb +226 -0
  33. data/lib/clacky/{patch_loader.rb → extension/patch_loader.rb} +45 -0
  34. data/lib/clacky/extension/scaffold/template_renderer.rb +64 -0
  35. data/lib/clacky/extension/scaffold/templates/full/README.md.erb +48 -0
  36. data/lib/clacky/extension/scaffold/templates/full/agents/designer.md +5 -0
  37. data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
  38. data/lib/clacky/extension/scaffold/templates/full/channels/noop.rb.erb +40 -0
  39. data/lib/clacky/extension/scaffold/templates/full/ext.yml.erb +41 -0
  40. data/lib/clacky/extension/scaffold/templates/full/hooks/audit.rb +9 -0
  41. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +194 -0
  42. data/lib/clacky/extension/scaffold/templates/full/patches/audit.rb +15 -0
  43. data/lib/clacky/extension/scaffold/templates/full/skills/__slug__-skill/SKILL.md.erb +8 -0
  44. data/lib/clacky/extension/scaffold/templates/hello/api/handler.rb.erb +9 -0
  45. data/lib/clacky/extension/scaffold/templates/hello/ext.yml.erb +12 -0
  46. data/lib/clacky/extension/scaffold/templates/hello/panels/hello/view.js.erb +31 -0
  47. data/lib/clacky/extension/scaffold.rb +55 -0
  48. data/lib/clacky/extension/verifier.rb +196 -0
  49. data/lib/clacky/identity.rb +70 -0
  50. data/lib/clacky/locales/en.rb +19 -1
  51. data/lib/clacky/locales/zh.rb +19 -1
  52. data/lib/clacky/platform_http_client.rb +18 -15
  53. data/lib/clacky/server/channel/extension_adapter_loader.rb +32 -0
  54. data/lib/clacky/server/channel.rb +6 -0
  55. data/lib/clacky/server/http_server.rb +313 -144
  56. data/lib/clacky/server/session_registry.rb +9 -2
  57. data/lib/clacky/skill_loader.rb +28 -1
  58. data/lib/clacky/ui2/ui_controller.rb +9 -6
  59. data/lib/clacky/version.rb +1 -1
  60. data/lib/clacky/web/app.css +748 -573
  61. data/lib/clacky/web/app.js +63 -15
  62. data/lib/clacky/web/auth.js +2 -0
  63. data/lib/clacky/web/components/notify.js +2 -0
  64. data/lib/clacky/web/components/sidebar.js +1 -5
  65. data/lib/clacky/web/core/ext.js +316 -79
  66. data/lib/clacky/web/features/backup/store.js +1 -0
  67. data/lib/clacky/web/features/backup/view.js +0 -1
  68. data/lib/clacky/web/features/brand/view.js +13 -5
  69. data/lib/clacky/web/features/extensions/store.js +184 -0
  70. data/lib/clacky/web/features/extensions/view.js +389 -0
  71. data/lib/clacky/web/features/new-session/store.js +186 -0
  72. data/lib/clacky/web/features/new-session/view.js +319 -0
  73. data/lib/clacky/web/features/share/view.js +63 -14
  74. data/lib/clacky/web/features/skills/store.js +3 -1
  75. data/lib/clacky/web/features/skills/view.js +1 -1
  76. data/lib/clacky/web/features/tasks/view.js +12 -0
  77. data/lib/clacky/web/features/trash/store.js +24 -6
  78. data/lib/clacky/web/features/trash/view.js +46 -9
  79. data/lib/clacky/web/features/workspace/store.js +2 -0
  80. data/lib/clacky/web/features/workspace/view.js +0 -1
  81. data/lib/clacky/web/i18n.js +118 -106
  82. data/lib/clacky/web/index.html +138 -188
  83. data/lib/clacky/web/sessions.js +64 -269
  84. data/lib/clacky/web/settings.js +1 -1
  85. data/lib/clacky/web/skills.js +2 -0
  86. data/lib/clacky/web/theme.js +2 -0
  87. data/lib/clacky/web/ws-dispatcher.js +1 -0
  88. data/lib/clacky/web/ws.js +2 -0
  89. data/lib/clacky.rb +21 -10
  90. metadata +55 -24
  91. data/benchmark/runner.rb +0 -441
  92. data/lib/clacky/api_extension_loader.rb +0 -168
  93. data/lib/clacky/default_agents/SOUL.md +0 -3
  94. data/lib/clacky/default_agents/USER.md +0 -1
  95. data/lib/clacky/default_agents/coding/profile.yml +0 -5
  96. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  97. data/lib/clacky/default_agents/general/profile.yml +0 -2
  98. data/lib/clacky/default_extensions/meeting/meta.yml +0 -3
  99. data/lib/clacky/web/features/creator/store.js +0 -81
  100. data/lib/clacky/web/features/creator/view.js +0 -380
  101. /data/lib/clacky/{default_agents → default_extensions/coding/agents}/coding/system_prompt.md +0 -0
  102. /data/lib/clacky/{default_agents → default_extensions/general/agents}/general/system_prompt.md +0 -0
  103. /data/lib/clacky/{default_agents/base_prompt.md → prompts/base.md} +0 -0
@@ -1,12 +1,17 @@
1
1
  // ── Clacky.ext — WebUI extension registry ─────────────────────────────────
2
2
  //
3
- // The single, controlled entry point through which user/AI-authored
4
- // extensions (loaded from ~/.clacky/webui_ext/) hook into the WebUI.
3
+ // The single, controlled entry point through which extensions declared
4
+ // in an ext.yml container hook into the WebUI.
5
5
  //
6
6
  // Three capabilities (the whole contract an extension author must learn):
7
7
  // Clacky.ext.api.register(name, fn) — register a data source
8
8
  // Clacky.ext.subscribe(event, handler) — listen to store events (read-only)
9
- // Clacky.ext.ui.mount(slot, renderFn) — inject UI into a named slot
9
+ // Clacky.ext.ui.mount(slot, spec, opts) — inject UI into a named slot
10
+ //
11
+ // `spec` is either a render function or { create?, render }. On per-session
12
+ // slots (session.aside/banner/composer), providing `create(ctx)` gives you
13
+ // one runtime per sessionId, isolated across sessions and disposed only
14
+ // when the session leaves the sidebar.
10
15
  //
11
16
  // Safety guarantees enforced here (the "constitution"):
12
17
  // - Every extension callback is wrapped in try/catch. A throwing extension is
@@ -23,9 +28,9 @@
23
28
  window.Clacky = window.Clacky || {};
24
29
 
25
30
  Clacky.ext = (() => {
26
- // Pure mode: detect ?pure=true once. When on, all registration is a no-op and
27
- // the host must not load any webui_ext scripts (handled server-side / in the
28
- // loader). This is the ultimate escape hatch back to a clean official UI.
31
+ // Pure mode: detect ?pure=true once. When on, all registration is a no-op
32
+ // and the host must not inject any extension scripts (handled server-side).
33
+ // This is the ultimate escape hatch back to a clean official UI.
29
34
  const PURE = (() => {
30
35
  try {
31
36
  return new URLSearchParams(window.location.search).get("pure") === "true";
@@ -37,6 +42,7 @@ Clacky.ext = (() => {
37
42
  const _dataSources = {}; // name => fn
38
43
  const _subscribers = {}; // event => [handler]
39
44
  const _slotRenderers = {}; // slot => [{ fn, extId, agents, panel, order, tab }]
45
+ const _workspaces = {}; // id => { id, title, render, extId }
40
46
  let _currentExtId = null; // set while an extension file is loading
41
47
 
42
48
  // Slots that render as a tabbed container instead of a vertical stack: each
@@ -46,6 +52,40 @@ Clacky.ext = (() => {
46
52
  // slot => active tab id (remembered across re-renders within a page load).
47
53
  const _activeTab = {};
48
54
 
55
+ // Slots that follow the current session's agent scope. A mount into any
56
+ // of these, made from inside a panel file, is confined to that panel's
57
+ // agents (via _currentPanel). Every other slot — sidebar, header, main
58
+ // workspace — is treated as global chrome: mounts there always show,
59
+ // regardless of which panel file happened to be loading. This is what
60
+ // separates "session UI" from "app chrome" and it is the ONLY place
61
+ // that distinction lives.
62
+ const SESSION_SCOPED_SLOTS = {
63
+ "session.banner": true,
64
+ "session.composer": true,
65
+ "session.aside": true,
66
+ "settings.tabs": true,
67
+ "settings.body": true,
68
+ };
69
+
70
+ // Named slots the host renders. Extensions mounting into any other name
71
+ // will silently do nothing — we warn once per bad name to catch typos
72
+ // like "sidebar-nav.top" or "session.aisde" during development.
73
+ const KNOWN_SLOTS = {
74
+ "header.left": true,
75
+ "header.right": true,
76
+ "sidebar.nav.top": true,
77
+ "sidebar.nav": true,
78
+ "sidebar.nav.bottom": true,
79
+ "sidebar.footer": true,
80
+ "main.workspace": true,
81
+ "session.banner": true,
82
+ "session.composer": true,
83
+ "session.aside": true,
84
+ "settings.tabs": true,
85
+ "settings.body": true,
86
+ };
87
+ const _warnedUnknownSlots = {};
88
+
49
89
  // Per-extension/panel agent scoping declared at load time:
50
90
  // _extAgents[extId] = ["coding", ...] (from <script data-agent=...>, may be multiple)
51
91
  // _panelAgents[panel] = ["coding", ...] (agents whose profile.yml references the panel)
@@ -72,15 +112,88 @@ Clacky.ext = (() => {
72
112
  };
73
113
  }
74
114
 
75
- // Bracket an extension's synchronous evaluation so registrations made during
76
- // it are attributed to `extId`. The host emits _extBegin before the
115
+ // Warn once per unknown slot so a typo is loud but the log stays finite.
116
+ function _warnUnknownSlot(slot, label) {
117
+ if (KNOWN_SLOTS[slot] || _warnedUnknownSlots[slot]) return;
118
+ _warnedUnknownSlots[slot] = true;
119
+ const known = Object.keys(KNOWN_SLOTS).join(", ");
120
+ console.warn(
121
+ `[Clacky.ext] unknown slot ${JSON.stringify(slot)} in ${label}. ` +
122
+ `Nothing will render. Known slots: ${known}`
123
+ );
124
+ }
125
+
126
+ // Slots whose renderer represents "the current session's copy" of some UI
127
+ // (a tabbed panel body, a session banner, a session-composer button). Mounts
128
+ // to these slots may declare `create(ctx)` and get one runtime per session,
129
+ // isolated from every other session and torn down only when the session
130
+ // itself leaves the sidebar.
131
+ const PER_SESSION_SLOTS = {
132
+ "session.aside": true,
133
+ "session.banner": true,
134
+ "session.composer": true,
135
+ };
136
+
137
+ // sessionId => Map<mountKey, runtime>. Populated lazily on first render.
138
+ const _sessionRuntimes = new Map();
139
+ // sessionId => Map<mountKey, teardownFn|null>. Cleared before every rerender.
140
+ const _viewTeardowns = new Map();
141
+
142
+ // Deterministic key for a renderer within a session: same slot + extId + tab
143
+ // id always resolves to the same runtime for a given session.
144
+ function _mountKey(slot, entry) {
145
+ const tabId = entry.tab && entry.tab.id ? entry.tab.id : "_";
146
+ return `${slot}::${entry.extId || "?"}::${tabId}`;
147
+ }
148
+
149
+ // Normalize a mount registration. Accepts either a plain function (render
150
+ // only) or an object { create?, render }. Anything else is rejected with a
151
+ // console warning to catch typos early.
152
+ function _register(slot, spec, opts, extId, extra) {
153
+ let create = null;
154
+ let render;
155
+ if (typeof spec === "function") {
156
+ render = spec;
157
+ } else if (spec && typeof spec === "object" && typeof spec.render === "function") {
158
+ render = spec.render;
159
+ if (typeof spec.create === "function") create = spec.create;
160
+ } else {
161
+ console.warn(
162
+ `[Clacky.ext] ui.mount(${JSON.stringify(slot)}) needs a render function ` +
163
+ `or { create?, render } object. Got: ${typeof spec}`
164
+ );
165
+ return;
166
+ }
167
+
168
+ const isBuiltin = !!(extra && extra.builtin);
169
+ const explicit = opts && Array.isArray(opts.agents) ? opts.agents : null;
170
+ const panel = (!isBuiltin && SESSION_SCOPED_SLOTS[slot]) ? _currentPanel : null;
171
+ const scoped = isBuiltin ? null : (explicit || _extAgents[extId] || null);
172
+ const order = opts && Number.isFinite(opts.order) ? opts.order : 100;
173
+ const tab = (opts && opts.tab) || null;
174
+ const workspace = (opts && typeof opts.workspace === "string") ? opts.workspace : null;
175
+
176
+ const label = `ui.mount(${slot})`;
177
+ (_slotRenderers[slot] ||= []).push({
178
+ render: _guard(render, label, extId),
179
+ create: create ? _guard(create, `${label}#create`, extId) : null,
180
+ extId,
181
+ agents: scoped,
182
+ panel,
183
+ order,
184
+ tab,
185
+ workspace,
186
+ });
187
+ }
188
+
189
+
77
190
  // extension's <script src> and _extEnd right after. In pure mode these are
78
191
  // no-ops (and the host does not emit extension scripts at all).
79
192
  //
80
- // `agents` scopes a single-agent extension (from agents/<name>/webui/): a list
81
- // of agent profile names it should appear for. `panel` marks an official
82
- // panel (from _panels/<id>/) whose agent scope is resolved separately via
83
- // registerPanelAgents. Either may be omitted for a global extension.
193
+ // `agents` scopes a single-agent extension (from user agents/<name>/webui/):
194
+ // a list of agent profile names it should appear for. `panel` marks a panel
195
+ // whose agent scope is resolved separately via registerPanelAgents. Either
196
+ // may be omitted for a global extension.
84
197
  function _extBegin(extId, agents, panel) {
85
198
  if (PURE) return;
86
199
  _currentExtId = extId;
@@ -137,50 +250,153 @@ Clacky.ext = (() => {
137
250
  const ui = {
138
251
  // Register a renderer for a named slot. renderFn(ctx) -> Node | string | null.
139
252
  //
140
- // opts.agents restrict to these agent profile names (default: all agents).
141
- // For panels, scope is taken from the panel's registerPanelAgents map and
142
- // merged with any explicit opts.agents.
143
- // opts.order vertical sort weight when several renderers share one slot
144
- // (lower renders first). Default 100; ties keep registration order.
145
- // opts.tab { id, label, badge? } required for tabbed slots: marks this
146
- // renderer as one tab in the slot's tab bar.
147
- mount(slot, renderFn, opts) {
148
- if (PURE || typeof renderFn !== "function") return;
149
- const explicit = opts && Array.isArray(opts.agents) ? opts.agents : null;
150
- const panel = (opts && opts.panel) || _currentPanel || null;
151
- const scoped = explicit || _extAgents[_currentExtId] || null;
152
- const order = opts && Number.isFinite(opts.order) ? opts.order : 100;
153
- const tab = (opts && opts.tab) || null;
154
- (_slotRenderers[slot] ||= []).push({
155
- fn: _guard(renderFn, `ui.mount(${slot})`, _currentExtId),
253
+ // Scope rules (automatic you never spell them out):
254
+ // Mounts to session/settings slots inherit the current panel's agent
255
+ // scope, so a `session.aside` tab written next to a designer panel
256
+ // only shows when the user is on the designer agent.
257
+ // Mounts to every other slot (sidebar.*, header.*, main.workspace)
258
+ // are global app chrome they show regardless of panel/agent.
259
+ //
260
+ // opts.agents — restrict to these agent profile names, overriding the
261
+ // defaults above. Rarely needed.
262
+ // Mount UI into a named slot. `spec` is either:
263
+ // • a function (ctx) => Node|string|null — plain render, no state
264
+ // an object { create?, render } — render + optional
265
+ // per-session runtime bound to `create`
266
+ //
267
+ // If the slot is per-session (session.aside etc.) AND `create` is present,
268
+ // the host allocates one runtime per sessionId — `create(ctx)` runs the
269
+ // first time this session shows the tab, `render(container, ctx, runtime)`
270
+ // runs every time the tab becomes visible with a fresh empty container,
271
+ // and `runtime.dispose()` runs when the session leaves the sidebar. State
272
+ // set up in `create` (media recorders, timers, subscriptions, buffers) is
273
+ // therefore isolated per session and survives tab teardown.
274
+ //
275
+ // For non-per-session slots or specs without `create`, the mount is a
276
+ // pure global renderer — one call per (re)render.
277
+ //
278
+ // opts.tab — { id, label, badge? } required for tabbed slots.
279
+ // opts.order — vertical sort weight (lower first). Default 100.
280
+ // opts.workspace — id of a registerWorkspace() workspace this mount opens;
281
+ // the host stamps it on the mount so the Router highlights this item
282
+ // while that workspace is active. Use on sidebar.nav.* items.
283
+ // opts.agents — override the automatic agent scope.
284
+ mount(slot, spec, opts) {
285
+ if (PURE || spec == null) return;
286
+ _warnUnknownSlot(slot, `ui.mount(${_currentExtId || "?"})`);
287
+ _register(slot, spec, opts, _currentExtId);
288
+ },
289
+
290
+ // Host-owned mount: identical to `mount` but bypasses PURE so official
291
+ // built-in tabs keep working in safe mode, and defaults to no agent scope.
292
+ mountBuiltin(slot, spec, opts) {
293
+ if (spec == null) return;
294
+ _warnUnknownSlot(slot, "ui.mountBuiltin");
295
+ _register(slot, spec, opts, "host", { builtin: true });
296
+ },
297
+
298
+ // Register a full-page workspace under this extension. When opened it
299
+ // takes over the main content area (host hides its own panels first) and
300
+ // gets its own URL hash `#ext/<id>` so back/forward + reload work.
301
+ //
302
+ // Clacky.ext.ui.registerWorkspace("my-console", {
303
+ // title: "My Console",
304
+ // render(container) { container.textContent = "hello"; },
305
+ // });
306
+ //
307
+ // A workspace is opened with `Clacky.ext.ui.openWorkspace(id)` — typically
308
+ // wired to a `sidebar.nav` menu item mounted from the same extension.
309
+ // render(container, ctx) is called every time the workspace is shown; the
310
+ // container is cleared beforehand so the render function can be dumb.
311
+ registerWorkspace(id, def) {
312
+ if (PURE || !id || !def || typeof def.render !== "function") return;
313
+ _workspaces[id] = {
314
+ id,
315
+ title: def.title || id,
316
+ render: _guard(def.render, `ui.registerWorkspace(${id})`, _currentExtId),
156
317
  extId: _currentExtId,
157
- agents: scoped, // explicit/per-extension agent list, or null = global
158
- panel, // official-panel id, or null
159
- order,
160
- tab, // { id, label, badge? } for tabbed slots, else null
161
- });
318
+ };
162
319
  },
163
320
 
164
- // Register a host-owned renderer (not attributed to any extension). Used for
165
- // built-in tabs that must appear for every session regardless of agent
166
- // scope (e.g. the Files tab). Bypasses PURE so the official UI keeps working
167
- // in safe mode. agents=null => visible everywhere.
168
- mountBuiltin(slot, renderFn, opts) {
169
- if (typeof renderFn !== "function") return;
170
- const order = opts && Number.isFinite(opts.order) ? opts.order : 100;
171
- const tab = (opts && opts.tab) || null;
172
- (_slotRenderers[slot] ||= []).push({
173
- fn: _guard(renderFn, `ui.mountBuiltin(${slot})`, "host"),
174
- extId: "host",
175
- agents: null,
176
- panel: null,
177
- order,
178
- tab,
179
- });
321
+ // Open a registered workspace. Emits a `clacky:ext:navigate` event that
322
+ // the host router listens for; ext.js has no compile-time dependency on
323
+ // the router. No-op if the id was never registered.
324
+ openWorkspace(id) {
325
+ if (PURE || !_workspaces[id]) return;
326
+ document.dispatchEvent(new CustomEvent("clacky:ext:navigate", {
327
+ detail: { view: "ext-workspace", params: { id } },
328
+ }));
180
329
  },
181
330
  };
182
331
 
183
- // Decide whether a renderer is visible under the current agent profile.
332
+ // Return the per-session runtime for a mount, allocating it via `create` on
333
+ // first use. Global mounts (or per-session slots without `create`) return
334
+ // null — the render function is expected to work stateless.
335
+ function _getRuntime(slot, entry, ctx) {
336
+ if (!entry.create) return null;
337
+ if (!PER_SESSION_SLOTS[slot]) return null;
338
+ const sessionId = ctx && ctx.sessionId;
339
+ if (!sessionId) return null;
340
+
341
+ let bySession = _sessionRuntimes.get(sessionId);
342
+ if (!bySession) {
343
+ bySession = new Map();
344
+ _sessionRuntimes.set(sessionId, bySession);
345
+ }
346
+ const key = _mountKey(slot, entry);
347
+ let runtime = bySession.get(key);
348
+ if (!runtime) {
349
+ runtime = entry.create(ctx);
350
+ if (runtime === undefined) return null; // create threw (guarded)
351
+ bySession.set(key, runtime || {});
352
+ }
353
+ return runtime;
354
+ }
355
+
356
+ // Run any view-level teardown collected on the previous render pass for the
357
+ // current session, so old event listeners / RAF loops don't leak when a tab
358
+ // is re-rendered or the session switches away.
359
+ function _runViewTeardowns(sessionId) {
360
+ if (!sessionId) return;
361
+ const map = _viewTeardowns.get(sessionId);
362
+ if (!map) return;
363
+ map.forEach((fn) => { if (typeof fn === "function") { try { fn(); } catch (_e) {} } });
364
+ map.clear();
365
+ }
366
+
367
+ function _rememberViewTeardown(sessionId, key, teardown) {
368
+ if (!sessionId || typeof teardown !== "function") return;
369
+ let map = _viewTeardowns.get(sessionId);
370
+ if (!map) { map = new Map(); _viewTeardowns.set(sessionId, map); }
371
+ map.set(key, teardown);
372
+ }
373
+
374
+ // Invoke a renderer's render function. Handles the (container, ctx, runtime)
375
+ // signature used everywhere; if the render returns a function it is stored
376
+ // as the view teardown, run before the next render or on session exit.
377
+ function _invokeRender(slot, entry, container, ctx) {
378
+ const runtime = _getRuntime(slot, entry, ctx);
379
+ let node, teardown, crashed = false;
380
+ try {
381
+ const out = entry.render(container, ctx, runtime);
382
+ if (out === undefined) {
383
+ crashed = false; // container mutated in-place is fine
384
+ } else if (typeof out === "function") {
385
+ teardown = out;
386
+ } else {
387
+ node = out;
388
+ }
389
+ } catch (_e) {
390
+ crashed = true;
391
+ }
392
+ if (crashed) return { crashed: true };
393
+ if (ctx && ctx.sessionId && teardown) {
394
+ _rememberViewTeardown(ctx.sessionId, _mountKey(slot, entry), teardown);
395
+ }
396
+ return { node };
397
+ }
398
+
399
+
184
400
  // null agents AND null panel => global (always visible). Otherwise the
185
401
  // current profile must be in the renderer's agent list, or in the set of
186
402
  // agents that reference its panel.
@@ -215,34 +431,27 @@ Clacky.ext = (() => {
215
431
  const ordered = renderers.slice().sort((a, b) => a.order - b.order);
216
432
  ordered.forEach((entry) => {
217
433
  if (!_visibleFor(entry, profile)) return;
218
- const { fn, extId } = entry;
219
- let node;
220
- let crashed = false;
221
- try {
222
- node = fn(renderCtx);
223
- if (node === undefined) crashed = true; // guard swallowed a throw
224
- } catch (_e) {
225
- crashed = true; // defensive: should already be guarded
226
- }
227
-
434
+ const wrap = document.createElement("div");
435
+ if (entry.extId) wrap.setAttribute("data-ext-id", entry.extId);
436
+ // Nav mounts that open a workspace declare it via opts.workspace; the
437
+ // host stamps it so the Router alone owns the active highlight.
438
+ if (entry.workspace) wrap.setAttribute("data-ext-workspace", entry.workspace);
439
+ const { node, crashed } = _invokeRender(slot, entry, wrap, renderCtx);
228
440
  if (crashed) {
229
- const ph = document.createElement("div");
230
- ph.setAttribute("data-ext-status", "crashed");
231
- ph.setAttribute("data-ext-id", extId || "");
232
- ph.className = "ext-slot-crashed";
233
- ph.textContent = "Extension failed to render.";
234
- container.appendChild(ph);
441
+ wrap.setAttribute("data-ext-status", "crashed");
442
+ wrap.className = "ext-slot-crashed";
443
+ wrap.textContent = "Extension failed to render.";
444
+ container.appendChild(wrap);
235
445
  return;
236
446
  }
237
-
238
- if (node == null) return; // nothing to render is valid
239
- if (typeof node === "string") {
240
- const wrap = document.createElement("div");
241
- wrap.setAttribute("data-ext-id", extId || "");
242
- wrap.innerHTML = node;
447
+ if (node != null) {
448
+ if (typeof node === "string") wrap.innerHTML = node;
449
+ else wrap.appendChild(node);
450
+ }
451
+ // If render mutated `wrap` in-place (returned nothing) or produced a
452
+ // node, either way we only append when there's something to show.
453
+ if (wrap.childNodes.length > 0 || (node != null && typeof node === "string")) {
243
454
  container.appendChild(wrap);
244
- } else {
245
- container.appendChild(node);
246
455
  }
247
456
  });
248
457
  }
@@ -289,11 +498,7 @@ Clacky.ext = (() => {
289
498
  const localCtx = Object.assign({}, renderCtx, {
290
499
  setBadge: (n) => _setTabBadge(tabBtns[id], n),
291
500
  });
292
- let node, crashed = false;
293
- try {
294
- node = entry.fn(localCtx);
295
- if (node === undefined) crashed = true;
296
- } catch (_e) { crashed = true; }
501
+ const { node, crashed } = _invokeRender(slot, entry, body, localCtx);
297
502
  if (crashed) {
298
503
  const ph = document.createElement("div");
299
504
  ph.className = "ext-slot-crashed";
@@ -355,11 +560,38 @@ Clacky.ext = (() => {
355
560
 
356
561
  // Update the current session context (host calls this on every session
357
562
  // switch) and re-render all slots so panels match the new agent profile.
563
+ // Runs the previous session's view-level teardowns first (event listeners /
564
+ // RAF loops attached to now-dead DOM), but leaves per-session runtimes
565
+ // alive — the meeting recorder for session A keeps running while you're
566
+ // looking at B, and its captions are still there when you switch back.
358
567
  function setContext(next) {
568
+ const prevSessionId = context.sessionId;
359
569
  Object.assign(context, next || {});
570
+ if (prevSessionId && prevSessionId !== context.sessionId) {
571
+ _runViewTeardowns(prevSessionId);
572
+ }
360
573
  refreshSlots();
361
574
  }
362
575
 
576
+ // Called by the host when a session leaves the sidebar for good (deleted,
577
+ // archived, or the page is unloading). Runs every runtime's `dispose()` for
578
+ // that session so timers, media recorders, and network handles are released.
579
+ function notifySessionRemoved(sessionId) {
580
+ if (!sessionId) return;
581
+ _runViewTeardowns(sessionId);
582
+ _viewTeardowns.delete(sessionId);
583
+ const bySession = _sessionRuntimes.get(sessionId);
584
+ if (!bySession) return;
585
+ bySession.forEach((runtime) => {
586
+ if (runtime && typeof runtime.dispose === "function") {
587
+ try { runtime.dispose(); } catch (err) {
588
+ console.error("[Clacky.ext] runtime.dispose failed:", err);
589
+ }
590
+ }
591
+ });
592
+ _sessionRuntimes.delete(sessionId);
593
+ }
594
+
363
595
  // Re-render every named slot present in the DOM against the current context.
364
596
  // Idempotent: each slot's container is cleared before re-rendering.
365
597
  function refreshSlots() {
@@ -373,6 +605,7 @@ Clacky.ext = (() => {
373
605
  get pure() { return PURE; },
374
606
  context,
375
607
  setContext,
608
+ notifySessionRemoved,
376
609
  refreshSlots,
377
610
  registerPanelAgents,
378
611
  api,
@@ -381,6 +614,10 @@ Clacky.ext = (() => {
381
614
  emit,
382
615
  renderSlot,
383
616
  slots,
617
+ // Host-only: look up a registered workspace by id. Returns
618
+ // { id, title, render, extId } or undefined. Used by the Router when
619
+ // handling the `ext-workspace` view — extensions never call this.
620
+ _getWorkspace(id) { return _workspaces[id]; },
384
621
  _extBegin, // used by the loader; not part of the public extension API
385
622
  _extEnd, // used by the loader; not part of the public extension API
386
623
  };
@@ -113,3 +113,4 @@ const BackupStore = (() => {
113
113
  })();
114
114
 
115
115
  const Backup = BackupStore;
116
+ Clacky.Backup = Backup;
@@ -118,4 +118,3 @@ const BackupView = (() => {
118
118
  })();
119
119
 
120
120
  BackupView.init();
121
- window.Backup = Backup;
@@ -94,7 +94,7 @@ const BrandView = (() => {
94
94
  }
95
95
 
96
96
  function _goToLicenseInput() {
97
- Router.navigate("settings");
97
+ window.Clacky.Router.navigate("settings");
98
98
  if (typeof Settings !== "undefined") Settings.open();
99
99
  setTimeout(() => {
100
100
  const generalTabBtn = document.querySelector('#settings-tabs .settings-tab[data-tab="general"]');
@@ -297,12 +297,20 @@ const BrandView = (() => {
297
297
  span.textContent = message;
298
298
  if (i18nKey) span.setAttribute("data-i18n", i18nKey);
299
299
 
300
- const btn = document.createElement("button");
301
- btn.innerHTML = "&#x2715;";
302
- btn.onclick = () => bar.remove();
300
+ const renewBtn = document.createElement("button");
301
+ renewBtn.className = "brand-warning-renew-btn";
302
+ renewBtn.textContent = I18n.t("brand.warning.renewBtn");
303
+ renewBtn.setAttribute("data-i18n", "brand.warning.renewBtn");
304
+ renewBtn.onclick = () => { bar.remove(); _goToLicenseInput(); };
305
+
306
+ const closeBtn = document.createElement("button");
307
+ closeBtn.className = "brand-warning-close-btn";
308
+ closeBtn.innerHTML = "&#x2715;";
309
+ closeBtn.onclick = () => bar.remove();
303
310
 
304
311
  bar.appendChild(span);
305
- bar.appendChild(btn);
312
+ bar.appendChild(renewBtn);
313
+ bar.appendChild(closeBtn);
306
314
  document.getElementById("main").prepend(bar);
307
315
  }
308
316