openclacky 0.9.30 → 0.9.32

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/lib/clacky/agent/llm_caller.rb +5 -5
  4. data/lib/clacky/agent/memory_updater.rb +1 -1
  5. data/lib/clacky/agent/session_serializer.rb +2 -1
  6. data/lib/clacky/agent/skill_auto_creator.rb +119 -0
  7. data/lib/clacky/agent/skill_evolution.rb +46 -0
  8. data/lib/clacky/agent/skill_manager.rb +8 -0
  9. data/lib/clacky/agent/skill_reflector.rb +97 -0
  10. data/lib/clacky/agent.rb +38 -12
  11. data/lib/clacky/agent_config.rb +10 -1
  12. data/lib/clacky/brand_config.rb +23 -0
  13. data/lib/clacky/cli.rb +1 -1
  14. data/lib/clacky/default_skills/onboard/SKILL.md +15 -7
  15. data/lib/clacky/default_skills/personal-website/publish.rb +1 -1
  16. data/lib/clacky/default_skills/skill-creator/SKILL.md +46 -0
  17. data/lib/clacky/json_ui_controller.rb +0 -4
  18. data/lib/clacky/message_history.rb +0 -12
  19. data/lib/clacky/plain_ui_controller.rb +19 -1
  20. data/lib/clacky/platform_http_client.rb +2 -4
  21. data/lib/clacky/providers.rb +12 -1
  22. data/lib/clacky/server/channel/channel_ui_controller.rb +0 -2
  23. data/lib/clacky/server/http_server.rb +13 -1
  24. data/lib/clacky/server/web_ui_controller.rb +55 -29
  25. data/lib/clacky/tools/shell.rb +91 -170
  26. data/lib/clacky/ui2/ui_controller.rb +100 -93
  27. data/lib/clacky/ui_interface.rb +0 -1
  28. data/lib/clacky/utils/arguments_parser.rb +5 -2
  29. data/lib/clacky/utils/limit_stack.rb +81 -13
  30. data/lib/clacky/version.rb +1 -1
  31. data/lib/clacky/web/app.css +247 -51
  32. data/lib/clacky/web/app.js +11 -3
  33. data/lib/clacky/web/brand.js +21 -3
  34. data/lib/clacky/web/creator.js +13 -2
  35. data/lib/clacky/web/i18n.js +41 -15
  36. data/lib/clacky/web/index.html +38 -20
  37. data/lib/clacky/web/sessions.js +256 -57
  38. data/lib/clacky/web/settings.js +32 -0
  39. data/lib/clacky/web/skills.js +61 -1
  40. metadata +4 -1
@@ -572,7 +572,15 @@ const Sessions = (() => {
572
572
  messages.scrollTop = messages.scrollHeight - scrollBefore;
573
573
  } else {
574
574
  // Initial load or append: scroll to bottom (user just opened session or sent message)
575
- messages.appendChild(frag);
575
+ // If a progress indicator is already visible (attached instantly on session switch),
576
+ // insert history above it so the progress element stays at the bottom.
577
+ const pState = Sessions._sessionProgress[id];
578
+ const existingProgressEl = pState && pState.el;
579
+ if (existingProgressEl && existingProgressEl.parentNode === messages) {
580
+ messages.insertBefore(frag, existingProgressEl);
581
+ } else {
582
+ messages.appendChild(frag);
583
+ }
576
584
  messages.scrollTop = messages.scrollHeight;
577
585
  // Flush any tool_stdout lines that arrived via WS before this history
578
586
  // fetch completed (race condition on session switch).
@@ -595,8 +603,10 @@ const Sessions = (() => {
595
603
  const session = _sessions.find(s => s.id === id);
596
604
  if (session) {
597
605
  if (session.status === "running") {
598
- // Agent is still running (e.g. page was refreshed mid-task)
599
- Sessions.showProgress(I18n.t("chat.thinking"));
606
+ // Progress UI is already attached (done eagerly in Router._apply).
607
+ // The backend's replay_live_state event will arrive shortly and call
608
+ // showProgress() with the authoritative started_at, which is the
609
+ // single source of truth for first-visit sessions (no cached state).
600
610
  } else if (session.status === "error" && session.error) {
601
611
  // Show the stored error message at the end of history
602
612
  Sessions.appendMsg("error", session.error);
@@ -768,6 +778,8 @@ const Sessions = (() => {
768
778
  remove(id) {
769
779
  const idx = _sessions.findIndex(s => s.id === id);
770
780
  if (idx !== -1) _sessions.splice(idx, 1);
781
+ // Clean up per-session progress state (timer + DOM + logical state)
782
+ Sessions._deleteProgressState(id);
771
783
  },
772
784
 
773
785
  /** Load the next page of older sessions (unified time cursor). */
@@ -964,6 +976,9 @@ const Sessions = (() => {
964
976
  * Called by Router before switching away from a session view. */
965
977
  _cacheActiveAndDeselect() {
966
978
  _cacheActiveMessages();
979
+ // Detach progress UI (DOM + timer) but preserve the logical state
980
+ // so it can be restored when the user switches back to this session.
981
+ if (_activeId) Sessions._detachProgressUI(_activeId);
967
982
  _activeId = null;
968
983
  WS.setSubscribedSession(null);
969
984
  Sessions.renderList();
@@ -1019,7 +1034,16 @@ const Sessions = (() => {
1019
1034
 
1020
1035
  // Scroll active session into view so the sidebar always shows the current session.
1021
1036
  const activeEl = list.querySelector(".session-item.active");
1022
- if (activeEl) activeEl.scrollIntoView({ block: "nearest" });
1037
+ if (activeEl) {
1038
+ // If the active session is the very first item, scroll to top of the sidebar
1039
+ // container so sticky headers / expanded panels don't obscure it.
1040
+ if (activeEl === list.firstElementChild) {
1041
+ const sidebarList = document.getElementById("sidebar-list");
1042
+ if (sidebarList) sidebarList.scrollTop = 0;
1043
+ } else {
1044
+ activeEl.scrollIntoView({ block: "nearest" });
1045
+ }
1046
+ }
1023
1047
  },
1024
1048
 
1025
1049
  /** Begin inline rename: replace session-name content with an <input>. */
@@ -1314,79 +1338,254 @@ const Sessions = (() => {
1314
1338
  _scrollToBottomIfNeeded(messages);
1315
1339
  },
1316
1340
 
1317
- _progressEl: null,
1318
- _progressInterval: null,
1319
- _progressStartTime: null,
1320
- _progressType: null,
1321
-
1322
- showProgress(text, progress_type = "thinking", metadata = {}) {
1323
- console.log("[DEBUG] showProgress called:", { text, progress_type, metadata });
1324
- Sessions.clearProgress();
1325
-
1326
- Sessions._progressType = progress_type;
1327
- Sessions._progressStartTime = Date.now();
1328
-
1341
+ // Display a request_user_feedback UI card with optional clickable option buttons.
1342
+ // Called when the agent needs user input to continue.
1343
+ showFeedbackRequest(question, context, options) {
1344
+ Sessions.collapseToolGroup();
1329
1345
  const messages = $("messages");
1330
- const el = document.createElement("div");
1331
- el.className = "progress-msg";
1332
-
1333
- // Choose display text based on type
1334
- let displayText;
1346
+ const hasOptions = options && Array.isArray(options) && options.length > 0;
1347
+
1348
+ // Normalize bullet symbols to markdown list format so marked renders them as <ul>
1349
+ const normalizeBullets = (text) => text ? text.replace(/^[•·‣▸▪\-–]\s*/gm, '- ') : text;
1350
+
1351
+ // No options → plain assistant bubble (card UI adds no value without choices)
1352
+ if (!hasOptions) {
1353
+ const parts = [context && context.trim(), question].filter(Boolean);
1354
+ const text = parts.map(normalizeBullets).join("\n\n");
1355
+ Sessions.appendMsg("assistant", marked.parse(text));
1356
+ return;
1357
+ }
1358
+
1359
+ // Has options → render interactive card
1360
+ const card = document.createElement("div");
1361
+ card.className = "feedback-card";
1362
+
1363
+ let cardHtml = "";
1364
+ if (context && context.trim()) {
1365
+ cardHtml += `<div class="feedback-context">${escapeHtml(context)}</div>`;
1366
+ }
1367
+ cardHtml += `<div class="feedback-question">${escapeHtml(question)}</div>`;
1368
+ cardHtml += `<div class="feedback-options">`;
1369
+ options.forEach((opt, idx) => {
1370
+ cardHtml += `<button class="feedback-option-btn" data-option-index="${idx}">${escapeHtml(opt)}</button>`;
1371
+ });
1372
+ cardHtml += `</div>`;
1373
+ cardHtml += `<div class="feedback-hint">${I18n.t("chat.feedback_hint")}</div>`;
1374
+
1375
+ card.innerHTML = cardHtml;
1376
+
1377
+ // Click → disable card + submit immediately via sendMessage()
1378
+ card.querySelectorAll(".feedback-option-btn").forEach(btn => {
1379
+ btn.onclick = () => {
1380
+ card.querySelectorAll(".feedback-option-btn").forEach(b => b.disabled = true);
1381
+ card.classList.add("feedback-card--submitted");
1382
+ const input = $("user-input");
1383
+ if (input) input.value = btn.textContent.trim();
1384
+ sendMessage();
1385
+ };
1386
+ });
1387
+
1388
+ messages.appendChild(card);
1389
+ _scrollToBottomIfNeeded(messages);
1390
+ },
1391
+
1392
+ // ── Per-session progress state ──────────────────────────────────────
1393
+ //
1394
+ // Each session maintains its own progress state so switching sessions
1395
+ // and switching back does NOT reset the elapsed timer.
1396
+ //
1397
+ // State map: { [sessionId]: { el, interval, startTime, type, displayText } }
1398
+ // el — DOM element (.progress-msg) currently in #messages (or null if detached)
1399
+ // interval — setInterval id for the ticking counter (or null if detached)
1400
+ // startTime — Date.now()-compatible ms timestamp when progress began
1401
+ // type — "thinking" | "retrying" | "idle_compress" | …
1402
+ // displayText — the label shown before the "(Ns)" suffix
1403
+
1404
+ _sessionProgress: {},
1405
+
1406
+ _getProgressState(id) {
1407
+ if (!id) return null;
1408
+ if (!Sessions._sessionProgress[id]) {
1409
+ Sessions._sessionProgress[id] = { el: null, interval: null, startTime: null, type: null, displayText: null };
1410
+ }
1411
+ return Sessions._sessionProgress[id];
1412
+ },
1413
+
1414
+ // Build the display label for a given progress type (pure — no side effects).
1415
+ _buildDisplayText(text, progress_type, metadata) {
1335
1416
  if (progress_type === "thinking") {
1336
- displayText = text || getRandomThinkingVerb();
1337
- console.log("[DEBUG] thinking verb:", displayText);
1417
+ return text || getRandomThinkingVerb();
1338
1418
  } else if (progress_type === "retrying") {
1339
- const { attempt, total } = metadata;
1340
- // Show error reason + retry count
1419
+ const { attempt, total } = metadata || {};
1341
1420
  if (text && attempt && total) {
1342
- displayText = `${I18n.t("chat.retrying")}: ${text} (${attempt}/${total})`;
1421
+ return `${I18n.t("chat.retrying")}: ${text} (${attempt}/${total})`;
1343
1422
  } else if (attempt && total) {
1344
- displayText = `${I18n.t("chat.retrying")} (${attempt}/${total})`;
1345
- } else {
1346
- displayText = text || I18n.t("chat.retrying");
1423
+ return `${I18n.t("chat.retrying")} (${attempt}/${total})`;
1347
1424
  }
1425
+ return text || I18n.t("chat.retrying");
1348
1426
  } else if (progress_type === "idle_compress") {
1349
- displayText = text || "Compressing...";
1350
- } else {
1351
- displayText = text || I18n.t("chat.thinking");
1427
+ return text || "Compressing...";
1352
1428
  }
1353
-
1354
- el.textContent = "⟳ " + displayText;
1355
- console.log("[DEBUG] appending progress element:", el.textContent);
1429
+ return text || I18n.t("chat.thinking");
1430
+ },
1431
+
1432
+ // Attach the progress UI (DOM element + setInterval) for a given session.
1433
+ // Requires the session's progress state to already have startTime + displayText set.
1434
+ _attachProgressUI(id) {
1435
+ const state = Sessions._getProgressState(id);
1436
+ if (!state || !state.startTime) return;
1437
+
1438
+ // Only attach if this session is currently visible
1439
+ if (id !== _activeId) return;
1440
+
1441
+ const messages = $("messages");
1442
+ if (!messages) return;
1443
+
1444
+ // Clean up any previous DOM/timer for this session (idempotent)
1445
+ Sessions._detachProgressUI(id);
1446
+
1447
+ const el = document.createElement("div");
1448
+ el.className = "progress-msg";
1449
+ const displayText = state.displayText;
1450
+ // Show elapsed time immediately (not just after first setInterval tick)
1451
+ const initialElapsed = Math.floor((Date.now() - state.startTime) / 1000);
1452
+ el.textContent = initialElapsed > 0
1453
+ ? `⟳ ${displayText}… (${initialElapsed}s)`
1454
+ : `⟳ ${displayText}`;
1356
1455
  messages.appendChild(el);
1357
- Sessions._progressEl = el;
1456
+ state.el = el;
1358
1457
  _scrollToBottomIfNeeded(messages);
1359
-
1458
+
1360
1459
  // Start elapsed time counter (update every second)
1361
- Sessions._progressInterval = setInterval(() => {
1362
- const elapsed = Math.floor((Date.now() - Sessions._progressStartTime) / 1000);
1363
- if (Sessions._progressEl) {
1364
- Sessions._progressEl.textContent = `⟳ ${displayText}… (${elapsed}s)`;
1460
+ state.interval = setInterval(() => {
1461
+ const elapsed = Math.floor((Date.now() - state.startTime) / 1000);
1462
+ if (state.el) {
1463
+ state.el.textContent = `⟳ ${displayText}… (${elapsed}s)`;
1365
1464
  }
1366
1465
  }, 1000);
1367
1466
  },
1368
1467
 
1369
- clearProgress(finalMessage = null) {
1370
- console.log("[DEBUG] clearProgress called:", finalMessage);
1371
- // Clear interval timer
1372
- if (Sessions._progressInterval) {
1373
- clearInterval(Sessions._progressInterval);
1374
- Sessions._progressInterval = null;
1468
+ // Detach only the DOM element and timer for a session, preserving logical state
1469
+ // (startTime, type, displayText). Called when switching away from a session.
1470
+ _detachProgressUI(id) {
1471
+ const state = Sessions._sessionProgress[id];
1472
+ if (!state) return;
1473
+ if (state.interval) {
1474
+ clearInterval(state.interval);
1475
+ state.interval = null;
1375
1476
  }
1376
-
1377
- // Remove progress element
1378
- if (Sessions._progressEl) {
1379
- Sessions._progressEl.remove();
1380
- Sessions._progressEl = null;
1477
+ if (state.el) {
1478
+ state.el.remove();
1479
+ state.el = null;
1381
1480
  }
1382
-
1481
+ },
1482
+
1483
+ showProgress(text, progress_type = "thinking", metadata = {}, startedAt = null) {
1484
+ const sid = _activeId;
1485
+ if (!sid) return;
1486
+
1487
+ const newStartTime = startedAt || Date.now();
1488
+ const newDisplayText = Sessions._buildDisplayText(text, progress_type, metadata);
1489
+
1490
+ // If this session already has a visible progress indicator (DOM element
1491
+ // attached), update it in-place instead of tear-down/rebuild. This avoids
1492
+ // the jarring flicker when replay_live_state arrives shortly after the
1493
+ // eager-attach on session switch.
1494
+ const existing = Sessions._sessionProgress[sid];
1495
+ if (existing && existing.el) {
1496
+ // If the start time is the same (same progress phase, e.g. dedup replay),
1497
+ // keep everything as-is — not even the display text changes.
1498
+ if (existing.startTime === newStartTime) {
1499
+ existing.type = progress_type;
1500
+ return;
1501
+ }
1502
+ // Different start time → new progress phase. Update state in-place and
1503
+ // restart the interval, but reuse the existing DOM element so the user
1504
+ // never sees the indicator disappear/reappear.
1505
+ existing.type = progress_type;
1506
+ existing.startTime = newStartTime;
1507
+ existing.displayText = newDisplayText;
1508
+ // Immediately refresh the text + elapsed counter
1509
+ const elapsed = Math.floor((Date.now() - newStartTime) / 1000);
1510
+ existing.el.textContent = elapsed > 0
1511
+ ? `⟳ ${newDisplayText}… (${elapsed}s)`
1512
+ : `⟳ ${newDisplayText}`;
1513
+ // Restart interval with new startTime
1514
+ if (existing.interval) clearInterval(existing.interval);
1515
+ existing.interval = setInterval(() => {
1516
+ const e = Math.floor((Date.now() - existing.startTime) / 1000);
1517
+ if (existing.el) {
1518
+ existing.el.textContent = `⟳ ${existing.displayText}… (${e}s)`;
1519
+ }
1520
+ }, 1000);
1521
+ _scrollToBottomIfNeeded($("messages"));
1522
+ return;
1523
+ }
1524
+
1525
+ // No existing visible progress — create from scratch.
1526
+ // Clear any stale logical state first.
1527
+ Sessions.clearProgress(sid);
1528
+
1529
+ const state = Sessions._getProgressState(sid);
1530
+ state.type = progress_type;
1531
+ state.startTime = newStartTime;
1532
+ state.displayText = newDisplayText;
1533
+
1534
+ // Attach DOM + timer
1535
+ Sessions._attachProgressUI(sid);
1536
+ },
1537
+
1538
+ clearProgress(sessionIdOrMessage = null, finalMessage = null) {
1539
+ // Backward-compatible overload resolution:
1540
+ // clearProgress() — clear active session
1541
+ // clearProgress("some message") — clear active session + final message
1542
+ // clearProgress(sessionId) — clear specific session (id looks like UUID)
1543
+ // clearProgress(sessionId, "message") — clear specific session + final message
1544
+ let sid;
1545
+ if (sessionIdOrMessage && typeof sessionIdOrMessage === "string") {
1546
+ // Heuristic: session IDs are UUIDs (contain hyphens or are 32+ hex chars).
1547
+ // Anything else is treated as a finalMessage for the active session.
1548
+ if (/^[0-9a-f-]{8,}$/i.test(sessionIdOrMessage)) {
1549
+ sid = sessionIdOrMessage;
1550
+ } else {
1551
+ finalMessage = sessionIdOrMessage;
1552
+ sid = _activeId;
1553
+ }
1554
+ } else {
1555
+ sid = _activeId;
1556
+ }
1557
+ if (!sid) return;
1558
+
1559
+ const state = Sessions._sessionProgress[sid];
1560
+ if (!state) return;
1561
+
1562
+ // Detach DOM + timer
1563
+ Sessions._detachProgressUI(sid);
1564
+
1383
1565
  // Show final message if provided (for idle_compress, etc.)
1384
- if (finalMessage && Sessions._progressType !== "thinking") {
1566
+ if (finalMessage && state.type && state.type !== "thinking") {
1385
1567
  Sessions.appendInfo(`· ${finalMessage}`);
1386
1568
  }
1387
-
1388
- Sessions._progressStartTime = null;
1389
- Sessions._progressType = null;
1569
+
1570
+ // Clear logical state
1571
+ state.startTime = null;
1572
+ state.type = null;
1573
+ state.displayText = null;
1574
+ },
1575
+
1576
+ // Delete all progress state for a session (used when session is removed).
1577
+ _deleteProgressState(id) {
1578
+ Sessions._detachProgressUI(id);
1579
+ delete Sessions._sessionProgress[id];
1580
+ },
1581
+
1582
+ // Clear progress for ALL sessions (used on WS disconnect).
1583
+ clearAllProgress() {
1584
+ for (const id of Object.keys(Sessions._sessionProgress)) {
1585
+ Sessions._detachProgressUI(id);
1586
+ }
1587
+ // Wipe the entire map — all state is stale after disconnect
1588
+ Sessions._sessionProgress = {};
1390
1589
  },
1391
1590
 
1392
1591
  // ── Create ─────────────────────────────────────────────────────────────
@@ -750,6 +750,38 @@ const Settings = (() => {
750
750
  document.getElementById("settings-license-key").focus();
751
751
  });
752
752
 
753
+ document.getElementById("btn-unbind-license").addEventListener("click", async () => {
754
+ const confirmed = await Modal.confirm(I18n.t("settings.brand.confirmUnbind"));
755
+ if (!confirmed) return;
756
+
757
+ try {
758
+ const res = await fetch("/api/brand/license", { method: "DELETE" });
759
+ const data = await res.json();
760
+
761
+ if (data.ok) {
762
+ // Clear brand name and logo from header
763
+ Brand.applyBrandName("OpenClacky");
764
+ Brand.clearBrandCache();
765
+ Brand.applyHeaderLogo();
766
+ // Reset Skills panel state (hide Brand Skills tab, switch to My Skills)
767
+ if (typeof Skills !== "undefined" && Skills.resetAfterUnbind) {
768
+ Skills.resetAfterUnbind();
769
+ }
770
+ // Hide status card, show activation form
771
+ document.getElementById("brand-status-card").style.display = "none";
772
+ document.getElementById("brand-activate-form").style.display = "";
773
+ document.getElementById("settings-license-key").value = "";
774
+ _showBrandResult(true, I18n.t("settings.brand.unbindSuccess"));
775
+ // Reload brand status after a brief delay
776
+ setTimeout(_loadBrand, 800);
777
+ } else {
778
+ _showBrandResult(false, data.error || I18n.t("settings.brand.unbindFailed"));
779
+ }
780
+ } catch (e) {
781
+ _showBrandResult(false, I18n.t("settings.brand.networkRetry"));
782
+ }
783
+ });
784
+
753
785
  _initLangBtns();
754
786
 
755
787
  // Re-render model cards when language changes (dynamic HTML, not data-i18n)
@@ -33,6 +33,12 @@ const Skills = (() => {
33
33
  $("skills-tab-my").style.display = tab === "my-skills" ? "" : "none";
34
34
  $("skills-tab-brand").style.display = tab === "brand-skills" ? "" : "none";
35
35
 
36
+ // Toggle visibility of tab-specific controls in the tab bar
37
+ const showSystemLabel = $("label-show-system");
38
+ const refreshBtn = $("btn-refresh-brand-skills");
39
+ if (showSystemLabel) showSystemLabel.style.display = tab === "my-skills" ? "" : "none";
40
+ if (refreshBtn) refreshBtn.style.display = tab === "brand-skills" ? "" : "none";
41
+
36
42
  // Lazy-load brand skills when the tab is first opened
37
43
  if (tab === "brand-skills" && _brandSkills.length === 0) {
38
44
  _loadBrandSkills();
@@ -359,7 +365,31 @@ const Skills = (() => {
359
365
  : _skills.filter(s => s.source !== "default");
360
366
 
361
367
  if (visible.length === 0) {
362
- container.innerHTML = `<div class="skills-empty">${I18n.t("skills.empty")}</div>`;
368
+ const emptyText = I18n.t("skills.empty");
369
+ const createBtnText = I18n.t("skills.empty.createBtn");
370
+
371
+ const emptyWrapper = document.createElement("div");
372
+ emptyWrapper.className = "skills-empty";
373
+
374
+ const emptyTextEl = document.createElement("div");
375
+ emptyTextEl.className = "skills-empty-text";
376
+ emptyTextEl.textContent = emptyText;
377
+
378
+ const createBtn = document.createElement("div");
379
+ createBtn.className = "skills-empty-create-btn";
380
+ createBtn.innerHTML = `
381
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
382
+ <path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2z"/><path d="M12 8v8"/><path d="M8 12h8"/>
383
+ </svg>
384
+ <span>${escapeHtml(createBtnText)}</span>
385
+ <svg class="skills-empty-create-arrow" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
386
+ <path d="M5 12h14"/><path d="M12 5l7 7-7 7"/>
387
+ </svg>`;
388
+ createBtn.addEventListener("click", () => Skills.createInSession("/skill-creator"));
389
+
390
+ emptyWrapper.appendChild(emptyTextEl);
391
+ emptyWrapper.appendChild(createBtn);
392
+ container.appendChild(emptyWrapper);
363
393
  } else {
364
394
  // System skills first, then custom
365
395
  const sorted = [
@@ -420,8 +450,21 @@ const Skills = (() => {
420
450
  const refreshBtn = $("btn-refresh-brand-skills");
421
451
  if (refreshBtn) {
422
452
  refreshBtn.addEventListener("click", async () => {
453
+ // Add spinning animation
454
+ const icon = refreshBtn.querySelector("svg");
455
+ if (icon) {
456
+ icon.classList.add("spinning");
457
+ }
458
+ refreshBtn.disabled = true;
459
+
423
460
  _brandSkills = [];
424
461
  await _loadBrandSkills();
462
+
463
+ // Remove spinning animation
464
+ if (icon) {
465
+ icon.classList.remove("spinning");
466
+ }
467
+ refreshBtn.disabled = false;
425
468
  });
426
469
  }
427
470
 
@@ -513,6 +556,23 @@ const Skills = (() => {
513
556
  _switchTab("brand-skills");
514
557
  },
515
558
 
559
+ /** Reset the skills panel back to My Skills tab and clear brand data.
560
+ * Called after license unbind so the user is not left on Brand Skills tab.
561
+ */
562
+ resetAfterUnbind() {
563
+ _brandSkills = [];
564
+ _brandActivated = false;
565
+ _activeTab = "my-skills";
566
+ // Hide the Brand Skills tab since there is no active license
567
+ const brandTab = $("tab-brand-skills");
568
+ if (brandTab) brandTab.style.display = "none";
569
+ // If the panel is currently visible, switch to My Skills immediately
570
+ const panel = $("skills-panel");
571
+ if (panel && panel.style.display !== "none") {
572
+ _switchTab("my-skills");
573
+ }
574
+ },
575
+
516
576
  // ── Import bar ────────────────────────────────────────────────────────
517
577
 
518
578
  /** Toggle the inline import bar below the My Skills header.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.30
4
+ version: 0.9.32
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy
@@ -305,7 +305,10 @@ files:
305
305
  - lib/clacky/agent/message_compressor.rb
306
306
  - lib/clacky/agent/message_compressor_helper.rb
307
307
  - lib/clacky/agent/session_serializer.rb
308
+ - lib/clacky/agent/skill_auto_creator.rb
309
+ - lib/clacky/agent/skill_evolution.rb
308
310
  - lib/clacky/agent/skill_manager.rb
311
+ - lib/clacky/agent/skill_reflector.rb
309
312
  - lib/clacky/agent/system_prompt_builder.rb
310
313
  - lib/clacky/agent/time_machine.rb
311
314
  - lib/clacky/agent/tool_executor.rb