openclacky 1.5.13 → 1.5.15

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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -0
  3. data/lib/clacky/agent/chunk_index.rb +83 -0
  4. data/lib/clacky/agent/history_navigation.rb +239 -0
  5. data/lib/clacky/agent/session_serializer.rb +53 -98
  6. data/lib/clacky/agent.rb +477 -297
  7. data/lib/clacky/agent_config.rb +5 -3
  8. data/lib/clacky/billing/billing_store.rb +2 -2
  9. data/lib/clacky/billing/platform_billing.rb +7 -0
  10. data/lib/clacky/brand_config.rb +27 -19
  11. data/lib/clacky/cli.rb +92 -12
  12. data/lib/clacky/cli_guidance.rb +48 -0
  13. data/lib/clacky/client.rb +2 -2
  14. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  15. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +4 -1
  16. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +307 -98
  17. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  18. data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
  19. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  20. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  21. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
  22. data/lib/clacky/extension/api_extension.rb +18 -6
  23. data/lib/clacky/extension/verifier.rb +1 -1
  24. data/lib/clacky/json_ui_controller.rb +4 -2
  25. data/lib/clacky/media/openai_compat.rb +70 -30
  26. data/lib/clacky/message_format/bedrock.rb +6 -1
  27. data/lib/clacky/message_format/open_ai.rb +22 -5
  28. data/lib/clacky/message_format/open_ai_responses.rb +7 -3
  29. data/lib/clacky/plain_ui_controller.rb +1 -1
  30. data/lib/clacky/prompts/base.md +1 -1
  31. data/lib/clacky/providers.rb +44 -13
  32. data/lib/clacky/rich_ui/components/composer_guidance.rb +49 -0
  33. data/lib/clacky/rich_ui/rich_ui_controller.rb +24 -4
  34. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +13 -0
  35. data/lib/clacky/search_config.rb +3 -3
  36. data/lib/clacky/server/channel/channel_manager.rb +25 -7
  37. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  38. data/lib/clacky/server/dir_picker.rb +67 -4
  39. data/lib/clacky/server/git_panel.rb +10 -2
  40. data/lib/clacky/server/http_server.rb +391 -75
  41. data/lib/clacky/server/preview.rb +351 -0
  42. data/lib/clacky/server/session_registry.rb +4 -0
  43. data/lib/clacky/server/web_ui_controller.rb +10 -5
  44. data/lib/clacky/session_manager.rb +52 -1
  45. data/lib/clacky/skill.rb +48 -0
  46. data/lib/clacky/tools/browser.rb +176 -19
  47. data/lib/clacky/tools/web_search.rb +91 -8
  48. data/lib/clacky/ui2/components/command_suggestions.rb +3 -1
  49. data/lib/clacky/ui2/components/input_area.rb +37 -4
  50. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  51. data/lib/clacky/ui2/screen_buffer.rb +1 -0
  52. data/lib/clacky/ui2/ui_controller.rb +72 -12
  53. data/lib/clacky/ui_interface.rb +1 -1
  54. data/lib/clacky/utils/file_processor.rb +19 -7
  55. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  56. data/lib/clacky/utils/model_pricing.rb +118 -68
  57. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  58. data/lib/clacky/version.rb +1 -1
  59. data/lib/clacky/web/app.css +998 -168
  60. data/lib/clacky/web/app.js +19 -0
  61. data/lib/clacky/web/components/chat-navigator.js +489 -202
  62. data/lib/clacky/web/components/code-editor.js +191 -5
  63. data/lib/clacky/web/components/composer.js +3 -1
  64. data/lib/clacky/web/components/datepicker.js +19 -19
  65. data/lib/clacky/web/components/mentions.js +11 -14
  66. data/lib/clacky/web/components/model-picker.js +66 -23
  67. data/lib/clacky/web/components/quote-select.js +341 -0
  68. data/lib/clacky/web/core/aside.js +157 -8
  69. data/lib/clacky/web/core/ext.js +16 -0
  70. data/lib/clacky/web/features/billing/view.js +75 -21
  71. data/lib/clacky/web/features/extensions/store.js +4 -1
  72. data/lib/clacky/web/features/model-tester/store.js +37 -4
  73. data/lib/clacky/web/features/skills/store.js +18 -1
  74. data/lib/clacky/web/features/skills/view.js +128 -7
  75. data/lib/clacky/web/features/workspace/store.js +80 -7
  76. data/lib/clacky/web/features/workspace/view.js +474 -43
  77. data/lib/clacky/web/i18n.js +158 -23
  78. data/lib/clacky/web/index.html +57 -29
  79. data/lib/clacky/web/sessions.js +517 -127
  80. data/lib/clacky/web/settings.js +155 -6
  81. data/lib/clacky/web/utils.js +34 -0
  82. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  83. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  84. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  85. data/lib/clacky/web/ws-dispatcher.js +161 -4
  86. data/lib/clacky.rb +2 -0
  87. metadata +13 -1
@@ -46,8 +46,11 @@
46
46
  "publish.already": "Already published. Enable \"publish a new version\" to ship.",
47
47
  "published.title": "Your published extensions",
48
48
  "published.empty": "You haven't published anything yet.",
49
+ "published.brandTitle": "Your brand extensions",
50
+ "published.brandEmpty": "You haven't published any brand extensions yet.",
49
51
  "btn.unpublish": "Unpublish",
50
52
  "published.confirm": "Unpublish {{id}} from the marketplace?",
53
+ "published.brandConfirm": "Unpublish {{id}} from your brand?",
51
54
  "err.generic": "Something went wrong: {{msg}}",
52
55
  "err.version_conflict": "Version {{ver}} has already been published. Please enter a higher version number above and try again.",
53
56
  "err.invalid_version": "Invalid version. Use format like 1.0.0.",
@@ -133,6 +136,8 @@
133
136
  "extlist.local.empty": "No local extensions. Create one below.",
134
137
  "extlist.badge.published": "Published",
135
138
  "extlist.badge.draft": "Draft",
139
+ "extlist.badge.unlisted": "Unlisted",
140
+ "extlist.unlisted.hint": "Taken down by the marketplace. Publishing a new version restores it.",
136
141
  "extlist.badge.local": "Not published",
137
142
  "extlist.verify.ok": "Checks passed",
138
143
  "extlist.verify.errors": "{{n}} error(s)",
@@ -140,6 +145,7 @@
140
145
  "extlist.btn.publish": "Publish to Marketplace",
141
146
  "extlist.btn.publishBrand": "Publish to Brand",
142
147
  "extlist.btn.update": "Update",
148
+ "extlist.btn.updateBrand": "Update Brand Version",
143
149
  "extlist.btn.pack": "Pack (.zip)",
144
150
  "extlist.btn.packing": "Packing…",
145
151
  "extlist.btn.unpublish": "Unpublish",
@@ -147,12 +153,14 @@
147
153
  "extlist.btn.disable": "Disable",
148
154
  "extlist.btn.enable": "Enable",
149
155
  "extlist.btn.delete": "Delete",
156
+ "extlist.btn.more": "More actions",
150
157
  "extlist.badge.disabled": "Disabled",
151
158
  "extlist.delete.confirm": "Delete local extension \"{{id}}\"? This cannot be undone.",
152
159
  "extlist.iterate.seed": "Iterate on extension {{id}}",
153
160
  "extlist.changelog.prompt": "Changelog (optional):",
154
161
  "extlist.overwrite.confirm": "\"{{id}}\" is already published. Publish a new version?",
155
162
  "extlist.unpublish.confirm": "Unpublish {{id}} from the marketplace?",
163
+ "extlist.unpublishBrand.confirm": "Unpublish {{id}} from your brand?",
156
164
  "extlist.publishing": "Publishing…",
157
165
  "extlist.needLicense": "Publishing requires an activated user license.",
158
166
  "extlist.brandLocked": "Publishing to the marketplace requires a creator license.",
@@ -244,8 +252,11 @@
244
252
  "publish.already": "已发布过。勾选「发布新版本」后再试。",
245
253
  "published.title": "你发布的扩展",
246
254
  "published.empty": "你还没有发布任何扩展。",
255
+ "published.brandTitle": "你发布到品牌的扩展",
256
+ "published.brandEmpty": "你还没有发布到品牌的扩展。",
247
257
  "btn.unpublish": "下架",
248
258
  "published.confirm": "确定要从市场下架 {{id}} 吗?",
259
+ "published.brandConfirm": "确定要从品牌下架 {{id}} 吗?",
249
260
  "err.generic": "出错了:{{msg}}",
250
261
  "err.version_conflict": "版本 {{ver}} 已发布过,请在上方输入更高的版本号后重试。",
251
262
  "err.invalid_version": "版本号格式不对,请使用如 1.0.0 的格式。",
@@ -331,6 +342,8 @@
331
342
  "extlist.local.empty": "本地暂无扩展,在下方新建一个。",
332
343
  "extlist.badge.published": "已发布",
333
344
  "extlist.badge.draft": "草稿",
345
+ "extlist.badge.unlisted": "已下架",
346
+ "extlist.unlisted.hint": "已被市场下架,重新发布新版本可恢复上架。",
334
347
  "extlist.badge.local": "未发布",
335
348
  "extlist.verify.ok": "检查通过",
336
349
  "extlist.verify.errors": "{{n}} 个错误",
@@ -338,6 +351,7 @@
338
351
  "extlist.btn.publish": "发布到市场",
339
352
  "extlist.btn.publishBrand": "发布到品牌",
340
353
  "extlist.btn.update": "更新到市场",
354
+ "extlist.btn.updateBrand": "更新到品牌",
341
355
  "extlist.btn.pack": "打包(.zip)",
342
356
  "extlist.btn.packing": "打包中…",
343
357
  "extlist.btn.unpublish": "下架",
@@ -345,12 +359,14 @@
345
359
  "extlist.btn.disable": "禁止",
346
360
  "extlist.btn.enable": "启用",
347
361
  "extlist.btn.delete": "删除",
362
+ "extlist.btn.more": "更多操作",
348
363
  "extlist.badge.disabled": "已禁用",
349
364
  "extlist.delete.confirm": "确定要删除本地扩展「{{id}}」吗?此操作不可恢复。",
350
365
  "extlist.iterate.seed": "迭代扩展 {{id}}",
351
366
  "extlist.changelog.prompt": "更新说明(可选):",
352
367
  "extlist.overwrite.confirm": "「{{id}}」已经发布过了。要发布新版本吗?",
353
368
  "extlist.unpublish.confirm": "确定要从市场下架 {{id}} 吗?",
369
+ "extlist.unpublishBrand.confirm": "确定要从品牌下架 {{id}} 吗?",
354
370
  "extlist.publishing": "发布中…",
355
371
  "extlist.needLicense": "发布需要已激活的用户授权。",
356
372
  "extlist.brandLocked": "发布到市场需要创作者权限。",
@@ -413,6 +429,10 @@
413
429
  return str;
414
430
  }
415
431
 
432
+ function isBrandOwner() {
433
+ return typeof Brand !== "undefined" && Brand.branded && Brand.userLicensed;
434
+ }
435
+
416
436
  function api(path) { return `/api/ext/ext-studio${path}`; }
417
437
 
418
438
  function toast(msg, type) {
@@ -501,6 +521,76 @@
501
521
  return node;
502
522
  }
503
523
 
524
+ // ── Shared: overflow menu ──────────────────────────────────────────────
525
+ // Mirrors the session list's "⋯" menu so buttons collapsed on narrow screens
526
+ // stay reachable. Items proxy the real buttons rather than duplicating them,
527
+ // which keeps transient label/disabled state (e.g. "Packing…") in sync.
528
+ const OVERFLOW_ICON = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="2.5" cy="7" r="1.2" fill="currentColor"/><circle cx="7" cy="7" r="1.2" fill="currentColor"/><circle cx="11.5" cy="7" r="1.2" fill="currentColor"/></svg>`;
529
+
530
+ let overflowDismiss = null;
531
+ let overflowAnchor = null;
532
+
533
+ function closeOverflowMenu() {
534
+ if (overflowDismiss) {
535
+ document.removeEventListener("click", overflowDismiss, true);
536
+ overflowDismiss = null;
537
+ }
538
+ overflowAnchor = null;
539
+ const existing = document.querySelector(".studio-overflow-menu");
540
+ if (existing) existing.remove();
541
+ }
542
+
543
+ function openOverflowMenu(anchor, buttons) {
544
+ // Re-tapping the same "⋯" toggles; tapping a different card must switch to it
545
+ // rather than merely dismissing the menu that is already open.
546
+ if (overflowAnchor === anchor) { closeOverflowMenu(); return; }
547
+ closeOverflowMenu();
548
+ overflowAnchor = anchor;
549
+ const menu = el("div", { class: "session-actions-menu studio-overflow-menu" });
550
+ buttons.forEach((btn) => {
551
+ const item = el("div", { class: "session-actions-menu-item" }, [
552
+ el("span", { class: "session-actions-menu-label", text: btn.textContent }),
553
+ ]);
554
+ if (btn.classList.contains("studio-btn-danger")) item.classList.add("session-actions-menu-item--danger");
555
+ if (btn.disabled) {
556
+ item.classList.add("is-disabled");
557
+ const tip = btn.getAttribute("data-tooltip");
558
+ if (tip) item.setAttribute("data-tooltip", tip);
559
+ } else {
560
+ item.addEventListener("click", () => { closeOverflowMenu(); btn.click(); });
561
+ }
562
+ menu.appendChild(item);
563
+ });
564
+
565
+ // Take the menu out of normal flow before measuring: as a static block child
566
+ // of <body> it would stretch to the full body width, so mw would be the
567
+ // viewport width and the clamp below would fling the menu to the far edge.
568
+ menu.style.visibility = "hidden";
569
+ menu.style.position = "fixed";
570
+ menu.style.top = "0px";
571
+ menu.style.left = "0px";
572
+ document.body.appendChild(menu);
573
+ const rect = anchor.getBoundingClientRect();
574
+ const mw = menu.offsetWidth;
575
+ const mh = menu.offsetHeight;
576
+ let top = rect.bottom + 4;
577
+ let left = rect.left;
578
+ if (top + mh > window.innerHeight - 8) top = Math.max(8, rect.top - mh - 4);
579
+ if (left + mw > window.innerWidth - 8) left = Math.max(8, window.innerWidth - mw - 8);
580
+ menu.style.top = top + "px";
581
+ menu.style.left = left + "px";
582
+ menu.style.visibility = "";
583
+
584
+ // Capture phase runs before the anchor's own handler, so the anchor has to be
585
+ // excluded here or reopening would immediately close and reopen the menu.
586
+ overflowDismiss = (e) => {
587
+ const node = e.target instanceof Element ? e.target : null;
588
+ if (node && (node.closest(".studio-overflow-menu") || node.closest(".studio-actions-overflow"))) return;
589
+ closeOverflowMenu();
590
+ };
591
+ document.addEventListener("click", overflowDismiss, true);
592
+ }
593
+
504
594
  // ── Shared: modal dialog ───────────────────────────────────────────────
505
595
  // A minimal overlay modal used by the publish-confirm and device-binding
506
596
  // flows. Returns the body element so callers can drive it live (bind flow),
@@ -1412,37 +1502,50 @@
1412
1502
  async function loadPublished() {
1413
1503
  const box = container.querySelector(".studio-published");
1414
1504
  if (!box) return;
1505
+ const brandOwner = isBrandOwner();
1506
+ const titleKey = brandOwner ? "published.brandTitle" : "published.title";
1507
+ const emptyKey = brandOwner ? "published.brandEmpty" : "published.empty";
1508
+ const confirmKey = brandOwner ? "published.brandConfirm" : "published.confirm";
1415
1509
  box.innerHTML = "";
1416
- box.appendChild(el("div", { class: "studio-label", text: t("published.title") }));
1510
+ box.appendChild(el("div", { class: "studio-label", text: t(titleKey) }));
1417
1511
  renderPublishedSkeleton(box);
1418
1512
  try {
1419
- const data = await getJson("/published");
1513
+ const endpoint = brandOwner ? "/published_brand" : "/published";
1514
+ const data = await getJson(endpoint);
1420
1515
  box.innerHTML = "";
1421
- box.appendChild(el("div", { class: "studio-label", text: t("published.title") }));
1422
- const exts = data.extensions || [];
1423
- if (!exts.length) { box.appendChild(el("p", { class: "studio-empty", text: t("published.empty") })); return; }
1516
+ box.appendChild(el("div", { class: "studio-label", text: t(titleKey) }));
1517
+ const exts = brandOwner
1518
+ ? (data.extensions || [])
1519
+ : (data.extensions || []).filter((e) => e.origin !== "self");
1520
+ if (!exts.length) { box.appendChild(el("p", { class: "studio-empty", text: t(emptyKey) })); return; }
1424
1521
  exts.forEach((e) => {
1425
1522
  const card = el("div", { class: "studio-skill-card" });
1426
1523
  const head = el("div", { class: "studio-skill-head" });
1524
+ const unlisted = listingState(e) === "unlisted";
1427
1525
  head.appendChild(el("span", { class: "studio-skill-name", text: e.name || e.id }));
1428
- const statusKind = e.status === "draft" ? "local" : "published";
1429
- head.appendChild(el("span", { class: `studio-skill-badge studio-skill-badge-${statusKind}`, text: e.status === "draft" ? t("extlist.badge.draft") : t("extlist.badge.published") }));
1526
+ const stateBadge = el("span", { class: `studio-skill-badge studio-skill-badge-${listingBadgeKind(e)}`, text: listingBadgeText(e) });
1527
+ if (unlisted) stateBadge.setAttribute("data-tooltip", t("extlist.unlisted.hint"));
1528
+ head.appendChild(stateBadge);
1430
1529
  if (e.version) head.appendChild(el("span", { style: "font-size:11px;color:var(--color-text-muted);", text: "v" + e.version }));
1431
- const unpubBtn = el("button", { class: "studio-btn studio-btn-danger studio-btn-sm", text: t("btn.unpublish") });
1432
- unpubBtn.style.marginLeft = "auto";
1433
- unpubBtn.addEventListener("click", async () => {
1434
- if (!await Clacky.Modal.confirm(t("published.confirm", { id: e.id }))) return;
1435
- unpubBtn.disabled = true;
1436
- try { await postJson("/unpublish", { ext_id: e.id }); loadPublished(); }
1437
- catch (err) { unpubBtn.disabled = false; feedback(t("err.generic", { msg: err.message }), "error"); }
1438
- });
1439
- head.appendChild(unpubBtn);
1530
+ // A taken-down extension has nothing left to unpublish — the only way
1531
+ // back on the marketplace is shipping a new version.
1532
+ if ((!brandOwner || e.origin === "self") && !unlisted) {
1533
+ const unpubBtn = el("button", { class: "studio-btn studio-btn-danger studio-btn-sm", text: t("btn.unpublish") });
1534
+ unpubBtn.style.marginLeft = "auto";
1535
+ unpubBtn.addEventListener("click", async () => {
1536
+ if (!await Clacky.Modal.confirm(t(confirmKey, { id: e.id }))) return;
1537
+ unpubBtn.disabled = true;
1538
+ try { await postJson("/unpublish", { ext_id: e.id }); loadPublished(); }
1539
+ catch (err) { unpubBtn.disabled = false; feedback(t("err.generic", { msg: err.message }), "error"); }
1540
+ });
1541
+ head.appendChild(unpubBtn);
1542
+ }
1440
1543
  card.appendChild(head);
1441
1544
  box.appendChild(card);
1442
1545
  });
1443
1546
  } catch (e) {
1444
1547
  box.innerHTML = "";
1445
- box.appendChild(el("div", { class: "studio-label", text: t("published.title") }));
1548
+ box.appendChild(el("div", { class: "studio-label", text: t(titleKey) }));
1446
1549
  box.appendChild(el("p", { class: "studio-empty", text: t("err.generic", { msg: e.message }) }));
1447
1550
  }
1448
1551
  }
@@ -1467,13 +1570,14 @@
1467
1570
  ));
1468
1571
 
1469
1572
  const bar = el("div", { class: "studio-actions" });
1470
- const brandNonAdmin = typeof Brand !== "undefined" && Brand.branded && !Brand.userLicensed;
1471
- const pubBtn = el("button", { class: "studio-btn studio-btn-primary studio-publish-btn", text: t("btn.publish"), onclick: doPublish });
1472
- pubBtn.disabled = brandNonAdmin;
1473
- if (brandNonAdmin) pubBtn.setAttribute("data-tooltip", t("extlist.brandLocked"));
1474
- bar.appendChild(pubBtn);
1475
- if (typeof Brand !== "undefined" && Brand.userLicensed) {
1476
- bar.appendChild(el("button", { class: "studio-btn studio-publish-brand-btn", text: t("btn.publishBrand"), onclick: doPublishBrand }));
1573
+ if (isBrandOwner()) {
1574
+ bar.appendChild(el("button", { class: "studio-btn studio-btn-primary studio-publish-brand-btn", text: t("btn.publishBrand"), onclick: doPublishBrand }));
1575
+ } else {
1576
+ const brandNonAdmin = typeof Brand !== "undefined" && Brand.branded && !Brand.userLicensed;
1577
+ const pubBtn = el("button", { class: "studio-btn studio-btn-primary studio-publish-btn", text: t("btn.publish"), onclick: doPublish });
1578
+ pubBtn.disabled = brandNonAdmin;
1579
+ if (brandNonAdmin) pubBtn.setAttribute("data-tooltip", t("extlist.brandLocked"));
1580
+ bar.appendChild(pubBtn);
1477
1581
  }
1478
1582
  const packBtn = el("button", { class: "studio-btn studio-btn-ghost studio-pack-btn", text: t("btn.pack"), onclick: doPack });
1479
1583
  bar.appendChild(packBtn);
@@ -1497,6 +1601,47 @@
1497
1601
  };
1498
1602
  }
1499
1603
 
1604
+ // Cloud / local (plus brand, for extensions) lists as a segmented control
1605
+ // instead of stacked sections — one list visible at a time. `state.active`
1606
+ // lives with the caller so the selection survives a rebuild.
1607
+ function sectionTabs(sections, state) {
1608
+ const bar = el("div", { class: "studio-seg-bar" });
1609
+ const seg = el("div", { class: "segmented-control" });
1610
+ const hint = el("span", { class: "studio-seg-hint" });
1611
+ const listBox = el("div", { class: "studio-seg-body" });
1612
+ bar.appendChild(seg);
1613
+ bar.appendChild(hint);
1614
+
1615
+ if (!sections.some((s) => s.id === state.active)) state.active = sections[0].id;
1616
+
1617
+ function paint() {
1618
+ const current = sections.filter((s) => s.id === state.active)[0] || sections[0];
1619
+ seg.innerHTML = "";
1620
+ sections.forEach((s) => {
1621
+ const btn = el("button", {
1622
+ class: "segmented-control__btn" + (s.id === current.id ? " active" : ""),
1623
+ text: s.label,
1624
+ });
1625
+ btn.addEventListener("click", () => {
1626
+ if (state.active === s.id) return;
1627
+ state.active = s.id;
1628
+ paint();
1629
+ });
1630
+ seg.appendChild(btn);
1631
+ });
1632
+ hint.textContent = current.hint;
1633
+ listBox.innerHTML = "";
1634
+ if (!current.items.length) {
1635
+ listBox.appendChild(el("p", { class: "studio-empty", text: t(current.emptyKey) }));
1636
+ } else {
1637
+ current.items.forEach((item) => listBox.appendChild(current.cardFn(item)));
1638
+ }
1639
+ }
1640
+ paint();
1641
+
1642
+ return [bar, listBox];
1643
+ }
1644
+
1500
1645
  function _skeletonHtml() {
1501
1646
  return Array.from({ length: 3 }).map(() => `
1502
1647
  <div class="studio-skill-card" style="pointer-events:none">
@@ -1514,6 +1659,29 @@
1514
1659
  </div>`).join("");
1515
1660
  }
1516
1661
 
1662
+ // An admin takedown keeps `status: "published"` on the platform record —
1663
+ // only hub_status flips (hidden/removed/quarantined). Map both fields to one
1664
+ // badge state so a taken-down extension never reads as live.
1665
+ function listingState(ext) {
1666
+ if (!ext) return "published";
1667
+ if (ext.status === "draft") return "draft";
1668
+ const hub = ext.hub_status;
1669
+ if (hub && hub !== "active") return "unlisted";
1670
+ return "published";
1671
+ }
1672
+
1673
+ function listingBadgeKind(ext) {
1674
+ const state = listingState(ext);
1675
+ if (state === "draft") return "local";
1676
+ return state === "unlisted" ? "unlisted" : "published";
1677
+ }
1678
+
1679
+ function listingBadgeText(ext) {
1680
+ const state = listingState(ext);
1681
+ if (state === "draft") return t("extlist.badge.draft");
1682
+ return state === "unlisted" ? t("extlist.badge.unlisted") : t("extlist.badge.published");
1683
+ }
1684
+
1517
1685
  // ── Extensions tab (full-page): cloud + local extension cards ──────────────
1518
1686
  // Reuses backend endpoints GET /published (cloud), the shared `store` (local,
1519
1687
  // GET /extensions), POST /publish|/pack|/unpublish, and POST /develop to open
@@ -1524,10 +1692,7 @@
1524
1692
  let brandCloud = [];
1525
1693
  let marketCloud = [];
1526
1694
  let brandPublished = [];
1527
-
1528
- function licensed() {
1529
- return !(typeof Brand !== "undefined" && Brand.branded && !Brand.userLicensed);
1530
- }
1695
+ const sectionState = { active: null };
1531
1696
 
1532
1697
  async function reload() {
1533
1698
  await store.reload();
@@ -1548,7 +1713,7 @@
1548
1713
  brandCloud = [];
1549
1714
  }
1550
1715
  marketCloud = cloud.filter((e) => e.origin !== "self");
1551
- brandPublished = cloud.filter((e) => e.origin === "self");
1716
+ brandPublished = isBrandOwner() ? brandCloud : cloud.filter((e) => e.origin === "self");
1552
1717
  }
1553
1718
 
1554
1719
  function createExtension(idea) {
@@ -1559,8 +1724,10 @@
1559
1724
  .catch((e) => alert(t("err.generic", { msg: e.message })));
1560
1725
  }
1561
1726
 
1562
- function badge(text, kind) {
1563
- return el("span", { class: "studio-skill-badge studio-skill-badge-" + kind, text });
1727
+ function badge(text, kind, tooltip) {
1728
+ const node = el("span", { class: "studio-skill-badge studio-skill-badge-" + kind, text });
1729
+ if (tooltip) node.setAttribute("data-tooltip", tooltip);
1730
+ return node;
1564
1731
  }
1565
1732
 
1566
1733
  function formatUnitCounts(counts) {
@@ -1575,9 +1742,9 @@
1575
1742
  function cloudCard(ext) {
1576
1743
  const card = el("div", { class: "studio-skill-card" });
1577
1744
  const head = el("div", { class: "studio-skill-head" });
1745
+ const unlisted = listingState(ext) === "unlisted";
1578
1746
  head.appendChild(el("span", { class: "studio-skill-name", text: ext.name || ext.id }));
1579
- const isDraft = ext.status === "draft";
1580
- head.appendChild(badge(isDraft ? t("extlist.badge.draft") : t("extlist.badge.published"), isDraft ? "local" : "published"));
1747
+ head.appendChild(badge(listingBadgeText(ext), listingBadgeKind(ext), unlisted ? t("extlist.unlisted.hint") : null));
1581
1748
  card.appendChild(head);
1582
1749
 
1583
1750
  const meta = el("div", { class: "studio-skill-meta" });
@@ -1586,27 +1753,37 @@
1586
1753
 
1587
1754
  const actions = el("div", { class: "studio-actions" });
1588
1755
  actions.appendChild(el("button", { class: "studio-btn studio-btn-primary", text: t("extlist.btn.iterate"), onclick: () => createExtension(t("extlist.iterate.seed", { id: ext.id })) }));
1589
- const un = el("button", { class: "studio-btn studio-btn-ghost", text: t("extlist.btn.unpublish") });
1590
- un.addEventListener("click", async () => {
1591
- if (!await Clacky.Modal.confirm(t("extlist.unpublish.confirm", { id: ext.id }))) return;
1592
- un.disabled = true;
1593
- try { await postJson("/unpublish", { ext_id: ext.id }); await reload(); rebuild(); }
1594
- catch (e) { un.disabled = false; alert(t("err.generic", { msg: e.message })); }
1595
- });
1596
- actions.appendChild(un);
1756
+ // A taken-down extension has nothing left to unpublish — the only way
1757
+ // back on the marketplace is shipping a new version.
1758
+ const canUnpublish = (!isBrandOwner() || ext.origin === "self") && !unlisted;
1759
+ if (canUnpublish) {
1760
+ const un = el("button", { class: "studio-btn studio-btn-ghost", text: t("extlist.btn.unpublish") });
1761
+ un.addEventListener("click", async () => {
1762
+ const confirmKey = ext.origin === "self" ? "extlist.unpublishBrand.confirm" : "extlist.unpublish.confirm";
1763
+ if (!await Clacky.Modal.confirm(t(confirmKey, { id: ext.id }))) return;
1764
+ un.disabled = true;
1765
+ try { await postJson("/unpublish", { ext_id: ext.id }); await reload(); rebuild(); }
1766
+ catch (e) { un.disabled = false; alert(t("err.generic", { msg: e.message })); }
1767
+ });
1768
+ actions.appendChild(un);
1769
+ }
1597
1770
  card.appendChild(actions);
1598
1771
  return card;
1599
1772
  }
1600
1773
 
1601
1774
  function localCard(ext) {
1775
+ const brandOwner = isBrandOwner();
1602
1776
  const cloudEntry = cloud.find((c) => c.id === ext.id);
1603
1777
  const marketEntry = marketCloud.find((c) => c.id === ext.id);
1604
- const published = !!cloudEntry;
1778
+ const brandEntry = brandCloud.find((c) => c.id === ext.id);
1779
+ const published = brandOwner ? !!brandEntry : !!marketEntry;
1780
+ const publishedAnywhere = !!cloudEntry;
1605
1781
  const marketPublished = !!marketEntry;
1606
1782
  const card = el("div", { class: "studio-skill-card" });
1607
1783
  const head = el("div", { class: "studio-skill-head" });
1608
1784
  head.appendChild(el("span", { class: "studio-skill-name", text: `${ext.name} (${ext.id})` }));
1609
- head.appendChild(badge(published ? t("extlist.badge.published") : t("extlist.badge.local"), published ? "published" : "local"));
1785
+ const publishedEntry = brandOwner ? brandEntry : marketEntry;
1786
+ head.appendChild(published ? badge(listingBadgeText(publishedEntry), listingBadgeKind(publishedEntry)) : badge(t("extlist.badge.local"), "local"));
1610
1787
  if (ext.disabled) {
1611
1788
  head.appendChild(badge(t("extlist.badge.disabled"), "changed"));
1612
1789
  } else if (ext.error_count) {
@@ -1626,41 +1803,51 @@
1626
1803
  card.appendChild(meta);
1627
1804
 
1628
1805
  const actions = el("div", { class: "studio-actions" });
1629
- const brandNonAdmin = typeof Brand !== "undefined" && Brand.branded && !Brand.userLicensed;
1630
- const pub = el("button", { class: "studio-btn studio-btn-primary", text: marketPublished ? t("extlist.btn.update") : t("extlist.btn.publish") });
1631
- pub.disabled = !!ext.error_count || brandNonAdmin;
1632
- const pubTip = ext.error_count ? t("extlist.verify.errors", { n: ext.error_count }) : (brandNonAdmin ? t("extlist.brandLocked") : "");
1633
- if (pubTip) pub.setAttribute("data-tooltip", pubTip);
1634
- pub.addEventListener("click", () => doPublish(ext, marketEntry ? marketEntry.version : null));
1635
- actions.appendChild(pub);
1636
-
1637
- if (typeof Brand !== "undefined" && Brand.userLicensed) {
1638
- const brandEntry = brandCloud.find((c) => c.id === ext.id);
1639
- const brandPub = el("button", { class: "studio-btn", text: t("extlist.btn.publishBrand") });
1806
+ if (brandOwner) {
1807
+ const brandPub = el("button", {
1808
+ class: "studio-btn studio-btn-primary",
1809
+ text: brandEntry ? t("extlist.btn.updateBrand") : t("extlist.btn.publishBrand"),
1810
+ });
1640
1811
  brandPub.disabled = !!ext.error_count;
1641
1812
  if (ext.error_count) brandPub.setAttribute("data-tooltip", t("extlist.verify.errors", { n: ext.error_count }));
1642
1813
  brandPub.addEventListener("click", () => doPublish(ext, brandEntry ? brandEntry.version : null, "self"));
1643
1814
  actions.appendChild(brandPub);
1815
+ } else {
1816
+ const brandNonAdmin = typeof Brand !== "undefined" && Brand.branded && !Brand.userLicensed;
1817
+ const pub = el("button", { class: "studio-btn studio-btn-primary", text: marketPublished ? t("extlist.btn.update") : t("extlist.btn.publish") });
1818
+ pub.disabled = !!ext.error_count || brandNonAdmin;
1819
+ const pubTip = ext.error_count ? t("extlist.verify.errors", { n: ext.error_count }) : (brandNonAdmin ? t("extlist.brandLocked") : "");
1820
+ if (pubTip) pub.setAttribute("data-tooltip", pubTip);
1821
+ pub.addEventListener("click", () => doPublish(ext, marketEntry ? marketEntry.version : null));
1822
+ actions.appendChild(pub);
1644
1823
  }
1645
1824
 
1646
1825
  actions.appendChild(el("button", { class: "studio-btn", text: t("extlist.btn.iterate"), onclick: () => createExtension(t("extlist.iterate.seed", { id: ext.id })) }));
1647
- const packBtn = el("button", { class: "studio-btn studio-btn-ghost", text: t("extlist.btn.pack") });
1826
+ const packBtn = el("button", { class: "studio-btn studio-btn-ghost studio-btn-collapsible", text: t("extlist.btn.pack") });
1648
1827
  packBtn.addEventListener("click", () => doPack(ext, packBtn));
1649
1828
  actions.appendChild(packBtn);
1650
1829
 
1651
1830
  const toggleBtn = el("button", {
1652
- class: "studio-btn studio-btn-ghost",
1831
+ class: "studio-btn studio-btn-ghost studio-btn-collapsible",
1653
1832
  text: ext.disabled ? t("extlist.btn.enable") : t("extlist.btn.disable"),
1654
1833
  });
1655
1834
  toggleBtn.addEventListener("click", () => doToggleDisabled(ext, toggleBtn));
1656
1835
  actions.appendChild(toggleBtn);
1657
1836
  card.appendChild(actions);
1658
1837
 
1659
- if (!published) {
1660
- const delBtn = el("button", { class: "studio-btn studio-btn-danger", text: t("extlist.btn.delete") });
1838
+ if (!publishedAnywhere) {
1839
+ const delBtn = el("button", { class: "studio-btn studio-btn-danger studio-btn-collapsible", text: t("extlist.btn.delete") });
1661
1840
  delBtn.addEventListener("click", () => doDelete(ext, delBtn));
1662
1841
  actions.appendChild(delBtn);
1663
1842
  }
1843
+
1844
+ const overflow = el("button", { class: "studio-btn studio-actions-overflow", "aria-label": t("extlist.btn.more"), title: t("extlist.btn.more") });
1845
+ overflow.innerHTML = OVERFLOW_ICON;
1846
+ overflow.addEventListener("click", (e) => {
1847
+ e.stopPropagation();
1848
+ openOverflowMenu(overflow, Array.from(actions.querySelectorAll(".studio-btn-collapsible")));
1849
+ });
1850
+ actions.appendChild(overflow);
1664
1851
  return card;
1665
1852
  }
1666
1853
 
@@ -1714,20 +1901,6 @@
1714
1901
  if (ok) { await reload(); rebuild(); }
1715
1902
  }
1716
1903
 
1717
- function section(titleKey, hintKey, items, cardFn, emptyKey) {
1718
- const box = el("div", { class: "studio-skill-section" });
1719
- const head = el("div", { class: "studio-skill-section-head" });
1720
- head.appendChild(el("span", { class: "studio-label", text: t(titleKey) }));
1721
- head.appendChild(el("span", { class: "studio-skill-hint", text: t(hintKey) }));
1722
- box.appendChild(head);
1723
- if (!items.length) {
1724
- box.appendChild(el("p", { class: "studio-empty", text: t(emptyKey) }));
1725
- } else {
1726
- items.forEach((s) => box.appendChild(cardFn(s)));
1727
- }
1728
- return box;
1729
- }
1730
-
1731
1904
  function rebuild(headerActions) {
1732
1905
  if (!container) return;
1733
1906
  container.innerHTML = "";
@@ -1745,11 +1918,21 @@
1745
1918
  headerActions.appendChild(newBtn);
1746
1919
  }
1747
1920
 
1748
- container.appendChild(section("extlist.section.cloud", "extlist.section.cloudHint", marketCloud, cloudCard, "extlist.cloud.empty"));
1749
- if (brandPublished.length > 0 || (typeof Brand !== "undefined" && Brand.userLicensed)) {
1750
- container.appendChild(section("extlist.section.brand", "extlist.section.brandHint", brandPublished, cloudCard, "extlist.brand.empty"));
1921
+ const sections = [{
1922
+ id: "local",
1923
+ label: t("extlist.section.local"),
1924
+ hint: t("extlist.section.localHint"),
1925
+ items: store.extensions,
1926
+ cardFn: localCard,
1927
+ emptyKey: "extlist.local.empty",
1928
+ }];
1929
+ if (!isBrandOwner()) {
1930
+ sections.push({ id: "cloud", label: t("extlist.section.cloud"), hint: t("extlist.section.cloudHint"), items: marketCloud, cardFn: cloudCard, emptyKey: "extlist.cloud.empty" });
1751
1931
  }
1752
- container.appendChild(section("extlist.section.local", "extlist.section.localHint", store.extensions, localCard, "extlist.local.empty"));
1932
+ if (brandPublished.length > 0 || isBrandOwner()) {
1933
+ sections.push({ id: "brand", label: t("extlist.section.brand"), hint: t("extlist.section.brandHint"), items: brandPublished, cardFn: cloudCard, emptyKey: "extlist.brand.empty" });
1934
+ }
1935
+ sectionTabs(sections, sectionState).forEach((node) => container.appendChild(node));
1753
1936
  }
1754
1937
 
1755
1938
  return {
@@ -1772,6 +1955,7 @@
1772
1955
  let cloud = [];
1773
1956
  let local = [];
1774
1957
  let locked = false;
1958
+ const sectionState = { active: null };
1775
1959
 
1776
1960
  async function reload() {
1777
1961
  const r = await getHost("/api/creator/skills");
@@ -1878,20 +2062,6 @@
1878
2062
  }
1879
2063
  }
1880
2064
 
1881
- function section(titleKey, hintKey, items, cardFn, emptyKey) {
1882
- const box = el("div", { class: "studio-skill-section" });
1883
- const head = el("div", { class: "studio-skill-section-head" });
1884
- head.appendChild(el("span", { class: "studio-label", text: t(titleKey) }));
1885
- head.appendChild(el("span", { class: "studio-skill-hint", text: t(hintKey) }));
1886
- box.appendChild(head);
1887
- if (!items.length) {
1888
- box.appendChild(el("p", { class: "studio-empty", text: t(emptyKey) }));
1889
- } else {
1890
- items.forEach((s) => box.appendChild(cardFn(s)));
1891
- }
1892
- return box;
1893
- }
1894
-
1895
2065
  function rebuild(headerActions) {
1896
2066
  if (!container) return;
1897
2067
  container.innerHTML = "";
@@ -1914,9 +2084,22 @@
1914
2084
  container.appendChild(lockHint);
1915
2085
  }
1916
2086
 
1917
- const cloudEmptyKey = locked ? "skills.cloud.locked" : "skills.cloud.empty";
1918
- container.appendChild(section("skills.section.cloud", "skills.section.cloudHint", cloud, cloudCard, cloudEmptyKey));
1919
- container.appendChild(section("skills.section.local", "skills.section.localHint", local, localCard, "skills.local.empty"));
2087
+ const sections = [{
2088
+ id: "local",
2089
+ label: t("skills.section.local"),
2090
+ hint: t("skills.section.localHint"),
2091
+ items: local,
2092
+ cardFn: localCard,
2093
+ emptyKey: "skills.local.empty",
2094
+ }, {
2095
+ id: "cloud",
2096
+ label: t("skills.section.cloud"),
2097
+ hint: t("skills.section.cloudHint"),
2098
+ items: cloud,
2099
+ cardFn: cloudCard,
2100
+ emptyKey: locked ? "skills.cloud.locked" : "skills.cloud.empty",
2101
+ }];
2102
+ sectionTabs(sections, sectionState).forEach((node) => container.appendChild(node));
1920
2103
  }
1921
2104
 
1922
2105
  return {
@@ -2002,13 +2185,33 @@
2002
2185
  return item;
2003
2186
  }
2004
2187
 
2188
+ let creatorNavItem = null;
2189
+ let brandStatusReady = false;
2190
+
2191
+ function syncCreatorNavVisibility() {
2192
+ if (!creatorNavItem) return;
2193
+ const brandNonAdmin = typeof Brand !== "undefined" && Brand.branded && !Brand.userLicensed;
2194
+ creatorNavItem.hidden = !brandStatusReady || brandNonAdmin;
2195
+ }
2196
+
2197
+ if (typeof Brand !== "undefined") {
2198
+ Brand.on("brand:status", function () {
2199
+ brandStatusReady = true;
2200
+ syncCreatorNavVisibility();
2201
+ });
2202
+ } else {
2203
+ brandStatusReady = true;
2204
+ }
2205
+
2005
2206
  Clacky.ext.ui.registerWorkspace(WS_ID, {
2006
2207
  title: t("ws.title"),
2007
2208
  render(container) { renderWorkspace(container); },
2008
2209
  });
2009
2210
 
2010
2211
  Clacky.ext.ui.mount("sidebar.nav.bottom", function () {
2011
- return navRow("nav.entry", function () { Clacky.ext.ui.openWorkspace(WS_ID); });
2212
+ creatorNavItem = navRow("nav.entry", function () { Clacky.ext.ui.openWorkspace(WS_ID); });
2213
+ syncCreatorNavVisibility();
2214
+ return creatorNavItem;
2012
2215
  }, { workspace: WS_ID });
2013
2216
 
2014
2217
  // Extension-developer session tools: debug + publish, as aside tabs. These
@@ -2041,7 +2244,7 @@
2041
2244
  .studio-detail-desc { margin: 4px 0 0; font-size: 12px; color: var(--color-text-secondary); line-height: 1.5; }
2042
2245
  .studio-units { display: flex; flex-wrap: wrap; gap: 6px; }
2043
2246
  .studio-unit-chip { display: inline-flex; background: var(--color-bg-hover); color: var(--color-text-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); padding: 2px 8px; font-size: 12px; }
2044
- .studio-actions { display: flex; align-items: center; gap: 8px; margin: 12px 0 0; }
2247
+ .studio-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 12px 0 0; }
2045
2248
  .studio-skill-promo .studio-actions { margin: 12px 0 0; }
2046
2249
  .studio-btn { padding: 7px 14px; border-radius: var(--radius-sm); border: 1px solid var(--color-border-primary); background: transparent; color: var(--color-text-secondary); cursor: pointer; font-size: 13px; font-weight: 500; }
2047
2250
  .studio-btn:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
@@ -2083,15 +2286,14 @@
2083
2286
  .studio-page-head-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
2084
2287
  .studio-page-title { margin: 0; font-size: 22px; font-weight: 600; color: var(--color-text-primary); }
2085
2288
  .studio-btn-sm { padding: 5px 12px; font-size: 12px; }
2289
+ .studio-seg-bar { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
2290
+ .studio-seg-hint { font-size: 11px; color: var(--color-text-muted); }
2086
2291
  .studio-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--color-border-primary); margin-bottom: 24px; }
2087
2292
  .studio-tab { padding: 8px 16px; border: none; background: transparent; color: var(--color-text-tertiary); cursor: pointer; font-size: 14px; font-weight: 500; border-bottom: 2px solid transparent; margin-bottom: -1px; }
2088
2293
  .studio-tab:hover { color: var(--color-text-primary); }
2089
2294
  .studio-tab-active { color: var(--color-text-primary); border-bottom-color: var(--color-accent-primary); }
2090
2295
  .studio-ext-block { margin-bottom: 20px; }
2091
2296
  .studio-block-title { font-size: 13px; font-weight: 600; color: var(--color-text-secondary); padding: 8px 0 0; }
2092
- .studio-skill-section { margin-bottom: 28px; }
2093
- .studio-skill-section-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; }
2094
- .studio-skill-hint { font-size: 11px; color: var(--color-text-muted); }
2095
2297
  .studio-skill-card { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md, 8px); padding: 12px; margin: 0 0 10px; background: var(--color-bg-secondary); }
2096
2298
  .studio-skill-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
2097
2299
  .studio-skill-name { font-size: 14px; font-weight: 600; color: var(--color-text-primary); }
@@ -2099,6 +2301,7 @@
2099
2301
  .studio-skill-badge-published { background: var(--color-success-bg, var(--color-bg-hover)); color: var(--color-success); }
2100
2302
  .studio-skill-badge-local { background: var(--color-bg-hover); color: var(--color-text-tertiary); }
2101
2303
  .studio-skill-badge-changed { background: var(--color-warning-bg, var(--color-bg-hover)); color: var(--color-warning, var(--color-text-secondary)); }
2304
+ .studio-skill-badge-unlisted { background: var(--color-warning-bg, var(--color-bg-hover)); color: var(--color-warning, var(--color-text-secondary)); }
2102
2305
  .studio-skill-badge-shadow { background: var(--color-bg-hover); color: var(--color-text-secondary); }
2103
2306
  .studio-skill-desc { margin: 6px 0 6px; font-size: 12px; color: var(--color-text-secondary); line-height: 1.5; }
2104
2307
  .studio-skill-meta { display: flex; gap: 12px; font-size: 11px; color: var(--color-text-muted); margin-top: 6px; }
@@ -2150,6 +2353,12 @@
2150
2353
  .studio-readme-preview pre code, .extension-readme-body pre code { background: none; padding: 0; }
2151
2354
  .studio-link-btn { background: none; border: none; padding: 0; font-size: 12px; color: var(--color-accent-primary); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
2152
2355
  .studio-link-btn:hover { color: var(--color-accent-hover); }
2356
+ .studio-actions-overflow { display: none; align-items: center; justify-content: center; align-self: stretch; box-sizing: border-box; padding: 7px 12px; }
2357
+ .studio-overflow-menu .session-actions-menu-item.is-disabled { opacity: 0.5; cursor: default; }
2358
+ @media (max-width: 768px) {
2359
+ .studio-actions > .studio-btn-collapsible { display: none; }
2360
+ .studio-actions > .studio-actions-overflow { display: inline-flex; }
2361
+ }
2153
2362
  `;
2154
2363
  document.head.appendChild(style);
2155
2364
  })();