openclacky 1.3.7 → 1.3.9

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/lib/clacky/agent/hook_manager.rb +10 -1
  4. data/lib/clacky/agent/system_prompt_builder.rb +1 -1
  5. data/lib/clacky/agent.rb +9 -2
  6. data/lib/clacky/agent_profile.rb +7 -4
  7. data/lib/clacky/billing/billing_store.rb +3 -2
  8. data/lib/clacky/brand_config.rb +2 -1
  9. data/lib/clacky/default_extensions/coding/agents/coding/avatar.png +0 -0
  10. data/lib/clacky/default_extensions/coding/ext.yml +4 -4
  11. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/avatar.png +0 -0
  12. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +0 -1
  13. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +4 -3
  14. data/lib/clacky/default_extensions/ext-studio/ext.yml +1 -1
  15. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +118 -50
  16. data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +1 -2
  17. data/lib/clacky/default_extensions/general/agents/general/avatar.png +0 -0
  18. data/lib/clacky/default_extensions/general/ext.yml +4 -4
  19. data/lib/clacky/default_extensions/git/ext.yml +0 -1
  20. data/lib/clacky/default_extensions/git/panels/git/view.js +3 -3
  21. data/lib/clacky/default_extensions/meeting/ext.yml +0 -1
  22. data/lib/clacky/default_extensions/meeting/panels/meeting/view.js +1 -2
  23. data/lib/clacky/default_extensions/time_machine/ext.yml +0 -1
  24. data/lib/clacky/extension/api_extension.rb +11 -2
  25. data/lib/clacky/extension/dispatcher.rb +3 -2
  26. data/lib/clacky/extension/loader.rb +6 -0
  27. data/lib/clacky/extension/packager.rb +0 -6
  28. data/lib/clacky/extension/verifier.rb +1 -1
  29. data/lib/clacky/server/http_server.rb +149 -5
  30. data/lib/clacky/shell_hook_loader.rb +266 -22
  31. data/lib/clacky/ui2/components/welcome_banner.rb +1 -1
  32. data/lib/clacky/utils/workspace_rules.rb +2 -2
  33. data/lib/clacky/version.rb +1 -1
  34. data/lib/clacky/web/app.css +334 -73
  35. data/lib/clacky/web/core/ext.js +130 -24
  36. data/lib/clacky/web/features/extensions/store.js +41 -6
  37. data/lib/clacky/web/features/extensions/view.js +44 -13
  38. data/lib/clacky/web/features/new-session/store.js +13 -0
  39. data/lib/clacky/web/features/new-session/view.js +314 -14
  40. data/lib/clacky/web/features/trash/view.js +4 -5
  41. data/lib/clacky/web/i18n.js +44 -2
  42. data/lib/clacky/web/index.html +54 -16
  43. data/lib/clacky/web/sessions.js +3 -3
  44. data/lib/clacky/web/skills.js +86 -48
  45. data/lib/clacky/web/theme.js +3 -0
  46. data/lib/clacky/web/ws-dispatcher.js +11 -2
  47. metadata +7 -7
@@ -8,10 +8,16 @@
8
8
  // Clacky.ext.subscribe(event, handler) — listen to store events (read-only)
9
9
  // Clacky.ext.ui.mount(slot, spec, opts) — inject UI into a named slot
10
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.
11
+ // `spec` is either a render function or { create?, render }. The render
12
+ // signature is always `render(container, ctx, runtime)`:
13
+ // - `container` is a host-owned DOM element append your UI into it,
14
+ // or return a DOM node / HTML string and the host appends it for you.
15
+ // - returning a function registers it as a teardown callback.
16
+ // - returning null is treated as an error (almost always a wrong
17
+ // signature, e.g. `(ctx) => ...`), surfaced as a crashed placeholder.
18
+ // On per-session slots (session.aside/banner/composer), providing
19
+ // `create(ctx)` gives you one runtime per sessionId, isolated across
20
+ // sessions and disposed only when the session leaves the sidebar.
15
21
  //
16
22
  // Safety guarantees enforced here (the "constitution"):
17
23
  // - Every extension callback is wrapped in try/catch. A throwing extension is
@@ -49,8 +55,6 @@ Clacky.ext = (() => {
49
55
  // renderer becomes one tab (chrome drawn by the host), and only the active
50
56
  // tab's body is shown. Renderers in these slots must carry opts.tab.
51
57
  const TABBED_SLOTS = { "session.aside": true };
52
- // slot => active tab id (remembered across re-renders within a page load).
53
- const _activeTab = {};
54
58
 
55
59
  // Slots that follow the current session's agent scope. A mount into any
56
60
  // of these, made from inside a panel file, is confined to that panel's
@@ -92,6 +96,7 @@ Clacky.ext = (() => {
92
96
  // Resolved by the host before the extension scripts run (see loader markers).
93
97
  const _extAgents = {};
94
98
  const _panelAgents = {};
99
+ const _agentContributions = {}; // agent id => { panels:[{id,title,title_zh}], skills:[...] }
95
100
  let _currentPanel = null; // set while an official panel file is loading
96
101
 
97
102
  // Current session context, kept in sync by the host on every session switch.
@@ -213,6 +218,11 @@ Clacky.ext = (() => {
213
218
  Object.assign(_panelAgents, map);
214
219
  }
215
220
 
221
+ function registerAgentContributions(map) {
222
+ if (PURE || !map) return;
223
+ Object.assign(_agentContributions, map);
224
+ }
225
+
216
226
  const api = {
217
227
  // Register a named data source. Host/extensions can later resolve it.
218
228
  register(name, fn) {
@@ -376,26 +386,119 @@ Clacky.ext = (() => {
376
386
  // as the view teardown, run before the next render or on session exit.
377
387
  function _invokeRender(slot, entry, container, ctx) {
378
388
  const runtime = _getRuntime(slot, entry, ctx);
379
- let node, teardown, crashed = false;
389
+ let node, teardown, crashed = false, reason = null;
380
390
  try {
381
391
  const out = entry.render(container, ctx, runtime);
382
- if (out === undefined) {
383
- crashed = false; // container mutated in-place is fine
392
+ if (out === undefined || out === null) {
393
+ // undefined container mutated in-place; null → renderer opted out of
394
+ // this context (e.g. `if (!ctx.sessionId) return null` on the new-session
395
+ // page). Both render nothing; neither is an error.
384
396
  } else if (typeof out === "function") {
385
397
  teardown = out;
386
398
  } else {
387
399
  node = out;
388
400
  }
389
- } catch (_e) {
401
+ } catch (err) {
402
+ reason = String((err && err.stack) || err);
403
+ console.error(`[Clacky.ext] extension "${entry.extId || "?"}" threw in render for slot ${JSON.stringify(slot)}:`, err);
390
404
  crashed = true;
391
405
  }
392
- if (crashed) return { crashed: true };
406
+ if (crashed) return { crashed: true, reason, extId: entry.extId };
393
407
  if (ctx && ctx.sessionId && teardown) {
394
408
  _rememberViewTeardown(ctx.sessionId, _mountKey(slot, entry), teardown);
395
409
  }
396
410
  return { node };
397
411
  }
398
412
 
413
+ // Build a styled, self-contained crashed-panel placeholder: a short headline
414
+ // plus a collapsible <details> exposing the actual reason and a nudge to the
415
+ // console. Never throws; the reason is rendered as plain text.
416
+ function _crashedPlaceholder(extId, reason) {
417
+ _injectCrashStyle();
418
+ const box = document.createElement("div");
419
+ box.className = "ext-slot-crashed";
420
+ box.setAttribute("data-ext-status", "crashed");
421
+
422
+ const head = document.createElement("div");
423
+ head.className = "ext-crashed-head";
424
+ const icon = document.createElement("span");
425
+ icon.className = "ext-crashed-icon";
426
+ icon.textContent = "!";
427
+ const title = document.createElement("span");
428
+ title.className = "ext-crashed-title";
429
+ title.textContent = extId ? `扩展「${extId}」渲染失败` : "扩展渲染失败";
430
+ head.appendChild(icon);
431
+ head.appendChild(title);
432
+ box.appendChild(head);
433
+
434
+ const hint = document.createElement("div");
435
+ hint.className = "ext-crashed-hint";
436
+ hint.textContent = "面板未能加载。展开下方详情,或打开浏览器控制台 (console) 查看完整错误。";
437
+ box.appendChild(hint);
438
+
439
+ if (reason) {
440
+ const details = document.createElement("details");
441
+ details.className = "ext-crashed-details";
442
+ const summary = document.createElement("summary");
443
+ summary.textContent = "错误详情";
444
+ const pre = document.createElement("pre");
445
+ pre.className = "ext-crashed-reason";
446
+ pre.textContent = reason;
447
+ details.appendChild(summary);
448
+ details.appendChild(pre);
449
+ box.appendChild(details);
450
+ }
451
+ return box;
452
+ }
453
+
454
+ let _crashStyleInjected = false;
455
+ function _injectCrashStyle() {
456
+ if (_crashStyleInjected) return;
457
+ _crashStyleInjected = true;
458
+ const style = document.createElement("style");
459
+ style.id = "ext-crashed-style";
460
+ style.textContent = `
461
+ .ext-slot-crashed {
462
+ margin: 12px;
463
+ padding: 14px 16px;
464
+ border: 1px solid var(--color-error-border, #fecaca);
465
+ background: var(--color-error-bg, #fef2f2);
466
+ border-radius: var(--radius-md, 8px);
467
+ color: var(--color-text-secondary, #56585e);
468
+ font-size: 13px;
469
+ line-height: 1.5;
470
+ }
471
+ .ext-crashed-head { display: flex; align-items: center; gap: 8px; }
472
+ .ext-crashed-icon {
473
+ display: inline-flex; align-items: center; justify-content: center;
474
+ width: 18px; height: 18px; flex: none;
475
+ border-radius: 50%;
476
+ background: var(--color-error, #ef4444);
477
+ color: #fff; font-size: 12px; font-weight: 700; line-height: 1;
478
+ }
479
+ .ext-crashed-title { font-weight: 600; color: var(--color-text-primary, #1a1b1e); }
480
+ .ext-crashed-hint { margin-top: 6px; color: var(--color-text-tertiary, #8a8d94); }
481
+ .ext-crashed-details { margin-top: 10px; }
482
+ .ext-crashed-details > summary {
483
+ cursor: pointer; user-select: none;
484
+ color: var(--color-error, #ef4444); font-weight: 500;
485
+ }
486
+ .ext-crashed-reason {
487
+ margin: 8px 0 0;
488
+ padding: 10px;
489
+ max-height: 220px; overflow: auto;
490
+ background: var(--color-bg-primary, #fbfbfa);
491
+ border: 1px solid var(--color-border-primary, #e8e8e4);
492
+ border-radius: var(--radius-sm, 6px);
493
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
494
+ font-size: 12px; line-height: 1.45;
495
+ white-space: pre-wrap; word-break: break-word;
496
+ color: var(--color-text-secondary, #56585e);
497
+ }
498
+ `;
499
+ (document.head || document.documentElement).appendChild(style);
500
+ }
501
+
399
502
 
400
503
  // null agents AND null panel => global (always visible). Otherwise the
401
504
  // current profile must be in the renderer's agent list, or in the set of
@@ -407,6 +510,13 @@ Clacky.ext = (() => {
407
510
  return false;
408
511
  }
409
512
 
513
+ // What a third-party agent's extension contributes (panels + skills), from
514
+ // ext.yml — so the new-session page can advertise it before a session exists.
515
+ // Returns { panels:[{id,title,title_zh}], skills:[...] } or null for built-ins.
516
+ function contributionsForAgent(agentId) {
517
+ return (agentId && _agentContributions[agentId]) || null;
518
+ }
519
+
410
520
  // Render every extension registered for `slot` into `container`, scoped to the
411
521
  // current agent profile. Called by the host's view layer wherever it exposes a
412
522
  // slot, and re-called on session switch (the container is cleared first so a
@@ -436,11 +546,9 @@ Clacky.ext = (() => {
436
546
  // Nav mounts that open a workspace declare it via opts.workspace; the
437
547
  // host stamps it so the Router alone owns the active highlight.
438
548
  if (entry.workspace) wrap.setAttribute("data-ext-workspace", entry.workspace);
439
- const { node, crashed } = _invokeRender(slot, entry, wrap, renderCtx);
549
+ const { node, crashed, reason, extId } = _invokeRender(slot, entry, wrap, renderCtx);
440
550
  if (crashed) {
441
- wrap.setAttribute("data-ext-status", "crashed");
442
- wrap.className = "ext-slot-crashed";
443
- wrap.textContent = "Extension failed to render.";
551
+ wrap.appendChild(_crashedPlaceholder(extId || entry.extId, reason));
444
552
  container.appendChild(wrap);
445
553
  return;
446
554
  }
@@ -478,17 +586,16 @@ Clacky.ext = (() => {
478
586
  const bodies = document.createElement("div");
479
587
  bodies.className = "aside-bodies";
480
588
 
481
- // Restore previously active tab if still present, else first tab.
589
+ // Always default to the first (highest-priority) tab. Tabs may register in
590
+ // several async passes; whichever pass runs last simply lands on tab #1.
482
591
  const ids = renderers.map((e) => e.tab.id);
483
- let active = _activeTab[slot];
484
- if (!active || !ids.includes(active)) active = ids[0];
592
+ const active = ids[0];
485
593
 
486
594
  const tabBtns = {};
487
595
  const bodyEls = {};
488
596
  const rendered = {};
489
597
 
490
598
  function activate(id) {
491
- _activeTab[slot] = id;
492
599
  Object.keys(tabBtns).forEach((k) => tabBtns[k].classList.toggle("active", k === id));
493
600
  Object.keys(bodyEls).forEach((k) => bodyEls[k].classList.toggle("active", k === id));
494
601
  if (!rendered[id]) {
@@ -498,12 +605,9 @@ Clacky.ext = (() => {
498
605
  const localCtx = Object.assign({}, renderCtx, {
499
606
  setBadge: (n) => _setTabBadge(tabBtns[id], n),
500
607
  });
501
- const { node, crashed } = _invokeRender(slot, entry, body, localCtx);
608
+ const { node, crashed, reason, extId } = _invokeRender(slot, entry, body, localCtx);
502
609
  if (crashed) {
503
- const ph = document.createElement("div");
504
- ph.className = "ext-slot-crashed";
505
- ph.textContent = "Extension failed to render.";
506
- body.appendChild(ph);
610
+ body.appendChild(_crashedPlaceholder(extId || entry.extId, reason));
507
611
  } else if (node != null) {
508
612
  if (typeof node === "string") body.innerHTML = node;
509
613
  else body.appendChild(node);
@@ -608,6 +712,8 @@ Clacky.ext = (() => {
608
712
  notifySessionRemoved,
609
713
  refreshSlots,
610
714
  registerPanelAgents,
715
+ registerAgentContributions,
716
+ contributionsForAgent,
611
717
  api,
612
718
  ui,
613
719
  subscribe,
@@ -19,9 +19,11 @@
19
19
 
20
20
  const ExtensionsStore = (() => {
21
21
  // ── State (single source of truth) ─────────────────────────────────────
22
- let _extensions = []; // [{ id, name, name_zh, description, ..., units }]
23
- let _query = ""; // current search text
24
- let _sort = "newest"; // "newest" | "updated" | "downloads"
22
+ let _extensions = []; // [{ id, name, name_zh, description, ..., units }]
23
+ let _allExtensions = []; // unfiltered result from server
24
+ let _query = ""; // current search text
25
+ let _sort = "newest"; // "newest" | "updated" | "downloads"
26
+ let _filterInstalled = false; // when true, show only installed extensions
25
27
  let _loading = false;
26
28
  let _error = null; // soft warning when the store is unreachable
27
29
  let _detail = null; // currently opened extension detail, or null
@@ -50,6 +52,7 @@ const ExtensionsStore = (() => {
50
52
  get extensions() { return _extensions; },
51
53
  get query() { return _query; },
52
54
  get sort() { return _sort; },
55
+ get filterInstalled() { return _filterInstalled; },
53
56
  get loading() { return _loading; },
54
57
  get error() { return _error; },
55
58
  get detail() { return _detail; },
@@ -73,7 +76,8 @@ const ExtensionsStore = (() => {
73
76
  const qs = params.toString();
74
77
  const res = await fetch("/api/store/extensions" + (qs ? "?" + qs : ""));
75
78
  const data = await res.json();
76
- _extensions = data.extensions || [];
79
+ _allExtensions = data.extensions || [];
80
+ _extensions = _filterInstalled ? _allExtensions.filter(e => e.installed) : _allExtensions;
77
81
  _error = data.warning || null;
78
82
  _loading = false;
79
83
  _emit("extensions:changed", { extensions: _extensions, warning: _error });
@@ -100,6 +104,13 @@ const ExtensionsStore = (() => {
100
104
  return Extensions.load();
101
105
  },
102
106
 
107
+ /** Toggle the "installed only" filter (client-side, no new network request). */
108
+ setFilterInstalled(onlyInstalled) {
109
+ _filterInstalled = !!onlyInstalled;
110
+ _extensions = _filterInstalled ? _allExtensions.filter(e => e.installed) : _allExtensions;
111
+ _emit("extensions:changed", { extensions: _extensions, warning: _error });
112
+ },
113
+
103
114
  /** Open the detail view for one extension (fetches contributes + versions). */
104
115
  async loadDetail(id) {
105
116
  if (!id) return;
@@ -156,6 +167,31 @@ const ExtensionsStore = (() => {
156
167
  }
157
168
  },
158
169
 
170
+ /** Install a marketplace extension by fetching its download_url then posting to the local server. */
171
+ async install(id) {
172
+ if (!id) return;
173
+ try {
174
+ const detailRes = await fetch("/api/store/extension?id=" + encodeURIComponent(id));
175
+ const detailData = await detailRes.json();
176
+ if (!detailRes.ok || !detailData.ok) throw new Error(detailData.error || "fetch detail failed");
177
+ const ext = detailData.extension;
178
+ const download_url = ext.download_url;
179
+ if (!download_url) throw new Error("No download URL available");
180
+ const res = await fetch("/api/store/extension/install", {
181
+ method: "POST",
182
+ headers: { "Content-Type": "application/json" },
183
+ body: JSON.stringify({ download_url, name: ext.name }),
184
+ });
185
+ const data = await res.json();
186
+ if (!res.ok || !data.ok) throw new Error(data.error || "install failed");
187
+ await Extensions.loadDetail(id);
188
+ } catch (e) {
189
+ console.error("[Extensions] install failed", e);
190
+ _detailError = e.message;
191
+ _emit("extensions:detail");
192
+ }
193
+ },
194
+
159
195
  /** Remove an installed extension, then return to the list. */
160
196
  async uninstall(id) {
161
197
  if (!id) return;
@@ -167,8 +203,7 @@ const ExtensionsStore = (() => {
167
203
  });
168
204
  const data = await res.json();
169
205
  if (!res.ok || !data.ok) throw new Error(data.error || "uninstall failed");
170
- Extensions.closeDetail();
171
- Extensions.load();
206
+ await Extensions.loadDetail(id);
172
207
  } catch (e) {
173
208
  console.error("[Extensions] uninstall failed", e);
174
209
  _detailError = e.message;
@@ -21,8 +21,8 @@ const ExtensionsView = (() => {
21
21
  container.innerHTML = Array.from({ length: 4 }).map(() => `
22
22
  <div class="extension-card">
23
23
  <div class="extension-card-main">
24
- <span class="skel" style="height:2rem;width:2rem;border-radius:8px;"></span>
25
- <div class="extension-info">
24
+ <span class="skel" style="height:2rem;width:2rem;border-radius:8px;flex-shrink:0"></span>
25
+ <div class="extension-info" style="display:flex;flex-direction:column;gap:0.375rem">
26
26
  <span class="skel skel-title"></span>
27
27
  <span class="skel skel-subtitle"></span>
28
28
  </div>
@@ -161,7 +161,8 @@ const ExtensionsView = (() => {
161
161
  return `
162
162
  <div class="extension-detail-head">
163
163
  <button type="button" class="extension-detail-back" id="extension-detail-back">
164
- ${escapeHtml(I18n.t("extensions.detail.back"))}
164
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9 2L4 7L9 12" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/></svg>
165
+ ${escapeHtml(I18n.t("extensions.detail.back"))}
165
166
  </button>
166
167
  </div>
167
168
  ${inner}`;
@@ -188,6 +189,16 @@ const ExtensionsView = (() => {
188
189
  if (ok) Extensions.uninstall(id);
189
190
  });
190
191
  }
192
+
193
+ const installBtn = document.querySelector("[data-ext-install]");
194
+ if (installBtn) {
195
+ installBtn.addEventListener("click", () => {
196
+ const id = installBtn.getAttribute("data-ext-install");
197
+ installBtn.disabled = true;
198
+ installBtn.textContent = I18n.t("extensions.action.installing");
199
+ Extensions.install(id);
200
+ });
201
+ }
191
202
  }
192
203
 
193
204
  function _backToList() {
@@ -214,6 +225,8 @@ const ExtensionsView = (() => {
214
225
  const homepageHtml = ext.homepage
215
226
  ? `<a class="extension-homepage" href="${escapeHtml(ext.homepage)}" target="_blank" rel="noopener noreferrer">${I18n.t("extensions.homepage")}</a>`
216
227
  : "";
228
+ const authorHtml = ext.author
229
+ ? `<span class="extension-detail-author">${escapeHtml(ext.author)}</span>` : "";
217
230
 
218
231
  return `
219
232
  <div class="extension-detail-hero">
@@ -225,10 +238,8 @@ const ExtensionsView = (() => {
225
238
  ${installedHtml}
226
239
  ${unitsHtml}
227
240
  </div>
228
- <div class="extension-desc">${escapeHtml(description)}</div>
229
- <div class="extension-meta">
230
- ${homepageHtml}
231
- </div>
241
+ ${description ? `<div class="extension-desc extension-desc-detail">${escapeHtml(description)}</div>` : ""}
242
+ ${(authorHtml || homepageHtml) ? `<div class="extension-meta">${authorHtml}${homepageHtml}</div>` : ""}
232
243
  ${_renderActions(ext)}
233
244
  </div>
234
245
  </div>
@@ -239,8 +250,17 @@ const ExtensionsView = (() => {
239
250
  // Manage buttons for a locally installed extension: enable/disable toggle
240
251
  // (always available when installed) plus remove (installed layer only).
241
252
  function _renderActions(ext) {
242
- if (!ext.installed) return "";
243
- const slug = ext.name || ext.slug || (ext.id != null ? String(ext.id) : "");
253
+ const id = ext.id != null ? String(ext.id) : (ext.name || ext.slug || "");
254
+ if (!ext.installed) {
255
+ if (ext.origin === "marketplace" && ext.download_url) {
256
+ return `
257
+ <div class="extension-detail-actions">
258
+ <button type="button" class="extension-action extension-action-install" data-ext-install="${escapeHtml(id)}">${escapeHtml(I18n.t("extensions.action.install"))}</button>
259
+ </div>`;
260
+ }
261
+ return "";
262
+ }
263
+ const slug = ext.name || ext.slug || id;
244
264
  const toggleKey = ext.disabled ? "extensions.action.enable" : "extensions.action.disable";
245
265
  const toggleCls = ext.disabled ? "extension-action-enable" : "extension-action-disable";
246
266
  const disabledBadge = ext.disabled
@@ -273,10 +293,13 @@ const ExtensionsView = (() => {
273
293
  const singular = type.replace(/s$/, "");
274
294
  const heading = _sectionHeading(type);
275
295
  const rows = items.map((it) => {
276
- const title = (currentLang === "zh" && it.title_zh) ? it.title_zh
277
- : (it.title || it.name || it.id || singular);
278
- const desc = (currentLang === "zh" && it.description_zh) ? it.description_zh
279
- : (it.description || "");
296
+ const isStr = typeof it === "string";
297
+ const title = isStr ? it
298
+ : ((currentLang === "zh" && it.title_zh) ? it.title_zh
299
+ : (it.title || it.name || it.id || singular));
300
+ const desc = isStr ? ""
301
+ : ((currentLang === "zh" && it.description_zh) ? it.description_zh
302
+ : (it.description || ""));
280
303
  return `
281
304
  <li class="extension-contrib-item">
282
305
  <span class="extension-contrib-title">${escapeHtml(String(title))}</span>
@@ -339,6 +362,14 @@ const ExtensionsView = (() => {
339
362
  sortSelect.addEventListener("change", () => Extensions.setSort(sortSelect.value));
340
363
  }
341
364
 
365
+ document.querySelectorAll(".extensions-filter-tab").forEach(btn => {
366
+ btn.addEventListener("click", () => {
367
+ document.querySelectorAll(".extensions-filter-tab").forEach(b => b.classList.remove("extensions-filter-tab-active"));
368
+ btn.classList.add("extensions-filter-tab-active");
369
+ Extensions.setFilterInstalled(btn.dataset.filter === "installed");
370
+ });
371
+ });
372
+
342
373
  const list = $("extensions-list");
343
374
  if (list) {
344
375
  list.addEventListener("click", (e) => {
@@ -168,6 +168,18 @@ const NewSessionStore = (() => {
168
168
  _emit("newSession:reset", {});
169
169
  }
170
170
 
171
+ async function fetchSkillsForAgent(agentId) {
172
+ try {
173
+ const res = await fetch(`/api/agents/${encodeURIComponent(agentId)}/skills`);
174
+ if (!res.ok) return [];
175
+ const data = await res.json();
176
+ return data.skills || [];
177
+ } catch (e) {
178
+ console.error("Failed to load skills:", e);
179
+ return [];
180
+ }
181
+ }
182
+
171
183
  return {
172
184
  get state() { return _state; },
173
185
  on: _on,
@@ -179,6 +191,7 @@ const NewSessionStore = (() => {
179
191
  loadModels,
180
192
  loadDefaultDirectory,
181
193
  createSession,
194
+ fetchSkillsForAgent,
182
195
  reset,
183
196
  };
184
197
  })();