openclacky 1.2.12 → 1.2.13

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/.clacky/skills/gem-release/SKILL.md +1 -1
  3. data/.clacky/skills/gem-release/scripts/release.sh +4 -1
  4. data/CHANGELOG.md +23 -0
  5. data/lib/clacky/agent/llm_caller.rb +40 -25
  6. data/lib/clacky/agent/memory_updater.rb +12 -0
  7. data/lib/clacky/agent/skill_auto_creator.rb +7 -4
  8. data/lib/clacky/agent/skill_evolution.rb +23 -5
  9. data/lib/clacky/agent/skill_manager.rb +86 -1
  10. data/lib/clacky/agent/skill_reflector.rb +18 -23
  11. data/lib/clacky/agent.rb +9 -1
  12. data/lib/clacky/agent_config.rb +59 -15
  13. data/lib/clacky/cli.rb +55 -0
  14. data/lib/clacky/default_skills/persist-memory/SKILL.md +4 -3
  15. data/lib/clacky/default_skills/search-skills/SKILL.md +61 -0
  16. data/lib/clacky/idle_compression_timer.rb +1 -1
  17. data/lib/clacky/message_format/open_ai.rb +7 -1
  18. data/lib/clacky/openai_stream_aggregator.rb +4 -1
  19. data/lib/clacky/providers.rb +40 -12
  20. data/lib/clacky/server/http_server.rb +117 -3
  21. data/lib/clacky/server/session_registry.rb +30 -8
  22. data/lib/clacky/server/web_ui_controller.rb +24 -1
  23. data/lib/clacky/session_manager.rb +120 -0
  24. data/lib/clacky/tools/web_search.rb +59 -8
  25. data/lib/clacky/ui2/layout_manager.rb +15 -5
  26. data/lib/clacky/ui2/progress_handle.rb +7 -1
  27. data/lib/clacky/ui2/ui_controller.rb +27 -0
  28. data/lib/clacky/ui_interface.rb +22 -0
  29. data/lib/clacky/utils/model_pricing.rb +96 -0
  30. data/lib/clacky/version.rb +1 -1
  31. data/lib/clacky/web/app.css +209 -4
  32. data/lib/clacky/web/app.js +6 -5
  33. data/lib/clacky/web/i18n.js +18 -4
  34. data/lib/clacky/web/index.html +2 -1
  35. data/lib/clacky/web/sessions.js +408 -80
  36. data/lib/clacky/web/settings.js +213 -51
  37. data/lib/clacky/web/skills.js +5 -14
  38. data/lib/clacky/web/utils.js +57 -0
  39. data/lib/clacky/web/ws-dispatcher.js +136 -0
  40. metadata +4 -2
@@ -1616,9 +1616,6 @@ const Settings = (() => {
1616
1616
  if (state.configured && state.model) return state.model;
1617
1617
  return "";
1618
1618
  }
1619
- if (state.source === "custom" && state.configured && !_mediaCustomDraft[kind]) {
1620
- return state.model || "";
1621
- }
1622
1619
  return "";
1623
1620
  }
1624
1621
 
@@ -1629,15 +1626,74 @@ const Settings = (() => {
1629
1626
  if (state.configured && state.model) {
1630
1627
  const wrap = document.createElement("div");
1631
1628
  wrap.className = "media-row-detail";
1632
- wrap.innerHTML = `
1633
- <div class="media-kv">
1634
- <span class="media-kv-key">${I18n.t("settings.media.field.provider")}</span>
1635
- <span class="media-kv-val">${_esc(state.provider || "")}</span>
1636
- <span class="media-kv-key">${I18n.t("settings.media.field.model")}</span>
1637
- <span class="media-kv-val">${_esc(state.model)}</span>
1638
- </div>
1639
- <div class="media-row-hint">${I18n.t("settings.media.auto.followsDefault")}</div>
1640
- `;
1629
+
1630
+ const providerLine = document.createElement("div");
1631
+ providerLine.className = "media-provider-line";
1632
+ const pLabel = document.createElement("span");
1633
+ pLabel.className = "media-provider-label";
1634
+ pLabel.textContent = I18n.t("settings.media.field.provider");
1635
+ const pVal = document.createElement("span");
1636
+ pVal.className = "media-provider-value";
1637
+ pVal.textContent = state.provider || "—";
1638
+ providerLine.appendChild(pLabel);
1639
+ providerLine.appendChild(pVal);
1640
+ wrap.appendChild(providerLine);
1641
+
1642
+ const modelField = _buildField(I18n.t("settings.media.field.model"));
1643
+ const opts = Array.isArray(state.available) ? state.available : [];
1644
+ const aliases = (state.aliases && typeof state.aliases === "object") ? state.aliases : {};
1645
+ const labelOf = (m) => aliases[m] ? `${m} (${aliases[m]})` : m;
1646
+ if (opts.length > 1) {
1647
+ const sel = document.createElement("select");
1648
+ sel.className = "field-select";
1649
+ opts.forEach(m => {
1650
+ const o = document.createElement("option");
1651
+ o.value = m;
1652
+ o.textContent = labelOf(m);
1653
+ if (m === state.model) o.selected = true;
1654
+ sel.appendChild(o);
1655
+ });
1656
+ sel.addEventListener("change", async () => {
1657
+ const picked = sel.value;
1658
+ const payload = picked === (def && def.model)
1659
+ ? { source: "auto" }
1660
+ : { source: "auto", model: picked };
1661
+ sel.disabled = true;
1662
+ _setMediaResult(kind, "testing", I18n.t("settings.media.action.saving"));
1663
+ try {
1664
+ await _saveMediaConfig(kind, payload);
1665
+ await _loadMedia();
1666
+ } catch (e) {
1667
+ sel.disabled = false;
1668
+ _setMediaResult(kind, "fail", e.message);
1669
+ }
1670
+ });
1671
+ modelField.appendChild(sel);
1672
+ } else {
1673
+ const ro = document.createElement("div");
1674
+ ro.className = "media-auto-readonly";
1675
+ ro.textContent = labelOf(state.model);
1676
+ modelField.appendChild(ro);
1677
+ }
1678
+ wrap.appendChild(modelField);
1679
+
1680
+ if (state.stale && state.requested_model) {
1681
+ const warn = document.createElement("div");
1682
+ warn.className = "media-row-hint is-warning";
1683
+ warn.textContent = I18n.t("settings.media.auto.stale", {
1684
+ requested: labelOf(state.requested_model),
1685
+ current: labelOf(state.model)
1686
+ });
1687
+ wrap.appendChild(warn);
1688
+ } else {
1689
+ const hint = document.createElement("div");
1690
+ hint.className = "media-row-hint";
1691
+ hint.textContent = I18n.t("settings.media.auto.followsDefault");
1692
+ wrap.appendChild(hint);
1693
+ }
1694
+
1695
+ wrap.appendChild(_buildMediaResult(kind));
1696
+
1641
1697
  return wrap;
1642
1698
  }
1643
1699
  const wrap = document.createElement("div");
@@ -1651,21 +1707,34 @@ const Settings = (() => {
1651
1707
  if (state.configured && !_mediaCustomDraft[kind]) {
1652
1708
  const wrap = document.createElement("div");
1653
1709
  wrap.className = "media-row-detail";
1654
- wrap.innerHTML = `
1655
- <div class="media-kv">
1656
- <span class="media-kv-key">${I18n.t("settings.media.field.model")}</span>
1657
- <span class="media-kv-val">${_esc(state.model || "—")}</span>
1658
- <span class="media-kv-key">${I18n.t("settings.media.field.baseUrl")}</span>
1659
- <span class="media-kv-val">${_esc(state.base_url || "—")}</span>
1660
- <span class="media-kv-key">${I18n.t("settings.media.field.apiKey")}</span>
1661
- <span class="media-kv-val">${_esc(state.api_key_masked || "—")}</span>
1662
- </div>
1663
- `;
1710
+
1711
+ const list = document.createElement("div");
1712
+ list.className = "media-custom-readonly-list";
1713
+ [
1714
+ ["settings.media.field.model", state.model],
1715
+ ["settings.media.field.baseUrl", state.base_url],
1716
+ ["settings.media.field.apiKey", state.api_key_masked]
1717
+ ].forEach(([lk, v]) => {
1718
+ const row = document.createElement("div");
1719
+ row.className = "media-custom-readonly-row";
1720
+ const lbl = document.createElement("span");
1721
+ lbl.className = "media-custom-readonly-label";
1722
+ lbl.textContent = I18n.t(lk);
1723
+ const val = document.createElement("span");
1724
+ val.className = "media-custom-readonly-value";
1725
+ val.textContent = v || "—";
1726
+ row.appendChild(lbl);
1727
+ row.appendChild(val);
1728
+ list.appendChild(row);
1729
+ });
1730
+ wrap.appendChild(list);
1731
+
1664
1732
  const actions = document.createElement("div");
1665
1733
  actions.className = "media-row-actions";
1734
+
1666
1735
  const editBtn = document.createElement("button");
1667
1736
  editBtn.type = "button";
1668
- editBtn.className = "media-row-btn";
1737
+ editBtn.className = "btn-secondary media-row-btn-sm";
1669
1738
  editBtn.textContent = I18n.t("settings.media.action.edit");
1670
1739
  editBtn.addEventListener("click", () => {
1671
1740
  _mediaCustomDraft[kind] = {
@@ -1675,8 +1744,33 @@ const Settings = (() => {
1675
1744
  };
1676
1745
  _renderMediaRows();
1677
1746
  });
1747
+
1748
+ const testBtn = document.createElement("button");
1749
+ testBtn.type = "button";
1750
+ testBtn.className = "btn-secondary media-row-btn-sm";
1751
+ testBtn.textContent = I18n.t("settings.media.action.test");
1752
+ testBtn.addEventListener("click", async () => {
1753
+ testBtn.disabled = true;
1754
+ editBtn.disabled = true;
1755
+ _setMediaResult(kind, "testing", I18n.t("settings.media.testing"));
1756
+ try {
1757
+ const r = await _testMediaConfig(kind, {
1758
+ model: state.model,
1759
+ base_url: state.base_url,
1760
+ api_key: state.api_key_masked
1761
+ });
1762
+ _setMediaResult(kind, r.ok ? "ok" : "fail", r.message || "");
1763
+ } finally {
1764
+ testBtn.disabled = false;
1765
+ editBtn.disabled = false;
1766
+ }
1767
+ });
1768
+
1769
+ actions.appendChild(testBtn);
1678
1770
  actions.appendChild(editBtn);
1679
1771
  wrap.appendChild(actions);
1772
+
1773
+ wrap.appendChild(_buildMediaResult(kind));
1680
1774
  return wrap;
1681
1775
  }
1682
1776
 
@@ -1684,37 +1778,58 @@ const Settings = (() => {
1684
1778
  const draft = _mediaCustomDraft[kind] || { model: "", base_url: "", api_key: "" };
1685
1779
  const wrap = document.createElement("div");
1686
1780
  wrap.className = "media-row-detail";
1687
- const form = document.createElement("div");
1688
- form.className = "media-custom-form";
1689
- form.innerHTML = `
1690
- <label>${I18n.t("settings.media.field.model")}</label>
1691
- <input type="text" data-field="model" value="${_esc(draft.model)}" placeholder="gpt-image-1">
1692
- <label>${I18n.t("settings.media.field.baseUrl")}</label>
1693
- <input type="text" data-field="base_url" value="${_esc(draft.base_url)}" placeholder="https://api.openai.com/v1">
1694
- <label>${I18n.t("settings.media.field.apiKey")}</label>
1695
- <input type="password" data-field="api_key" value="${_esc(draft.api_key)}" placeholder="${I18n.t("settings.media.apiKey.placeholder")}">
1696
- <div class="media-form-actions">
1697
- <button type="button" class="media-row-btn" data-act="cancel">${I18n.t("settings.media.action.cancel")}</button>
1698
- <button type="button" class="media-row-btn is-primary" data-act="save">${I18n.t("settings.media.action.save")}</button>
1699
- </div>
1700
- `;
1701
- form.querySelectorAll("input").forEach(inp => {
1781
+
1782
+ const fields = document.createElement("div");
1783
+ fields.className = "media-custom-fields";
1784
+
1785
+ const mkInput = (labelKey, fieldName, type, placeholder, initial) => {
1786
+ const f = _buildField(I18n.t(labelKey));
1787
+ const inp = document.createElement("input");
1788
+ inp.type = type;
1789
+ inp.className = "field-input";
1790
+ inp.value = initial || "";
1791
+ inp.placeholder = placeholder;
1792
+ inp.dataset.field = fieldName;
1702
1793
  inp.addEventListener("input", () => {
1703
1794
  _mediaCustomDraft[kind] = _mediaCustomDraft[kind] || {};
1704
- _mediaCustomDraft[kind][inp.dataset.field] = inp.value;
1795
+ _mediaCustomDraft[kind][fieldName] = inp.value;
1705
1796
  });
1706
- });
1707
- form.querySelector('[data-act="cancel"]').addEventListener("click", () => {
1797
+ f.appendChild(inp);
1798
+ return f;
1799
+ };
1800
+ fields.appendChild(mkInput("settings.media.field.model", "model", "text", "gpt-image-1", draft.model));
1801
+ fields.appendChild(mkInput("settings.media.field.baseUrl", "base_url", "text", "https://api.openai.com/v1", draft.base_url));
1802
+ fields.appendChild(mkInput("settings.media.field.apiKey", "api_key", "password", I18n.t("settings.media.apiKey.placeholder"), draft.api_key));
1803
+ wrap.appendChild(fields);
1804
+
1805
+ const result = _buildMediaResult(kind);
1806
+ wrap.appendChild(result);
1807
+
1808
+ const actions = document.createElement("div");
1809
+ actions.className = "media-row-actions";
1810
+
1811
+ const cancelBtn = document.createElement("button");
1812
+ cancelBtn.type = "button";
1813
+ cancelBtn.className = "btn-secondary media-row-btn-sm";
1814
+ cancelBtn.textContent = I18n.t("settings.media.action.cancel");
1815
+ cancelBtn.addEventListener("click", () => {
1708
1816
  delete _mediaCustomDraft[kind];
1709
- // If there's no saved custom, fall back to whatever the saved source is (or off)
1710
1817
  if (!state.configured) {
1711
1818
  const fallback = (_mediaDefaults && _mediaDefaults[kind] && _mediaDefaults[kind].model) ? "auto" : "off";
1712
1819
  _mediaState[kind] = { ..._mediaState[kind], source: fallback };
1713
1820
  }
1714
1821
  _renderMediaRows();
1715
1822
  });
1716
- form.querySelector('[data-act="save"]').addEventListener("click", async () => {
1823
+
1824
+ const saveBtn = document.createElement("button");
1825
+ saveBtn.type = "button";
1826
+ saveBtn.className = "btn-primary media-row-btn-sm";
1827
+ saveBtn.textContent = I18n.t("settings.media.action.save");
1828
+ saveBtn.addEventListener("click", async () => {
1717
1829
  const d = _mediaCustomDraft[kind] || {};
1830
+ saveBtn.disabled = true;
1831
+ cancelBtn.disabled = true;
1832
+ _setMediaResult(kind, "testing", I18n.t("settings.media.action.saving"));
1718
1833
  try {
1719
1834
  await _saveMediaConfig(kind, {
1720
1835
  source: "custom",
@@ -1725,24 +1840,55 @@ const Settings = (() => {
1725
1840
  delete _mediaCustomDraft[kind];
1726
1841
  await _loadMedia();
1727
1842
  } catch (e) {
1728
- alert(e.message);
1843
+ saveBtn.disabled = false;
1844
+ cancelBtn.disabled = false;
1845
+ _setMediaResult(kind, "fail", e.message);
1729
1846
  }
1730
1847
  });
1731
- wrap.appendChild(form);
1848
+
1849
+ actions.appendChild(cancelBtn);
1850
+ actions.appendChild(saveBtn);
1851
+ wrap.appendChild(actions);
1852
+
1732
1853
  return wrap;
1733
1854
  }
1734
1855
 
1856
+ function _buildField(labelText) {
1857
+ const f = document.createElement("div");
1858
+ f.className = "model-field";
1859
+ const label = document.createElement("span");
1860
+ label.className = "field-label";
1861
+ label.textContent = labelText;
1862
+ f.appendChild(label);
1863
+ return f;
1864
+ }
1865
+
1866
+ function _buildMediaResult(kind) {
1867
+ const el = document.createElement("div");
1868
+ el.className = "model-test-result";
1869
+ el.dataset.mediaKind = kind;
1870
+ return el;
1871
+ }
1872
+
1873
+ function _setMediaResult(kind, status, message) {
1874
+ const el = document.querySelector(`.model-test-result[data-media-kind="${kind}"]`);
1875
+ if (!el) return;
1876
+ el.className = `model-test-result result-${status}`;
1877
+ if (!message) {
1878
+ el.textContent = "";
1879
+ return;
1880
+ }
1881
+ const prefix = status === "ok" ? "✓ " : status === "fail" ? "✗ " : "";
1882
+ el.textContent = prefix + message;
1883
+ }
1884
+
1735
1885
  async function _onMediaSourceClick(kind, source) {
1736
1886
  const cur = (_mediaState && _mediaState[kind]) || {};
1737
1887
  if (cur.source === source && source !== "custom") return;
1738
1888
 
1739
1889
  if (source === "custom") {
1740
1890
  if (cur.source !== "custom" && !_mediaCustomDraft[kind]) {
1741
- _mediaCustomDraft[kind] = {
1742
- model: cur.model || "",
1743
- base_url: cur.base_url || "",
1744
- api_key: ""
1745
- };
1891
+ _mediaCustomDraft[kind] = { model: "", base_url: "", api_key: "" };
1746
1892
  }
1747
1893
  _mediaState[kind] = { ...cur, source: "custom" };
1748
1894
  _renderMediaRows();
@@ -1754,7 +1900,8 @@ const Settings = (() => {
1754
1900
  delete _mediaCustomDraft[kind];
1755
1901
  await _loadMedia();
1756
1902
  } catch (e) {
1757
- alert(e.message);
1903
+ _renderMediaRows();
1904
+ _setMediaResult(kind, "fail", e.message);
1758
1905
  }
1759
1906
  }
1760
1907
 
@@ -1771,6 +1918,21 @@ const Settings = (() => {
1771
1918
  return data;
1772
1919
  }
1773
1920
 
1921
+ async function _testMediaConfig(kind, { model, base_url, api_key }) {
1922
+ try {
1923
+ const res = await fetch(`/api/config/media/test`, {
1924
+ method: "POST",
1925
+ headers: { "Content-Type": "application/json" },
1926
+ body: JSON.stringify({ kind, model, base_url, api_key })
1927
+ });
1928
+ const data = await res.json().catch(() => ({}));
1929
+ if (!res.ok) return { ok: false, message: data.error || `HTTP ${res.status}` };
1930
+ return data;
1931
+ } catch (e) {
1932
+ return { ok: false, message: e.message };
1933
+ }
1934
+ }
1935
+
1774
1936
 
1775
1937
  function init() {
1776
1938
  _initTabs();
@@ -813,13 +813,7 @@ const SkillAC = (() => {
813
813
  // Load from localStorage, default to false (hide system skills)
814
814
  let _showSystemSkills = localStorage.getItem("skill-ac-show-system") === "true";
815
815
 
816
- // Cross-browser IME composition fix:
817
- // Safari fires compositionend BEFORE keydown (violating W3C spec), and the
818
- // gap between compositionend and keydown is ~5ms on Safari. We record the
819
- // timestamp of compositionend and treat any Enter keydown within 20ms as
820
- // still-composing. Chrome is unaffected because e.isComposing is still true.
821
- // Reference: https://bugs.webkit.org/show_bug.cgi?id=165004
822
- let _lastCompositionEndTime = -Infinity;
816
+ let _ime = null; // IME tracker for #user-input, set up in _initDOMBindings
823
817
 
824
818
  /** Called whenever the active session changes — just store the id, no prefetch. */
825
819
  function _loadForSession(sessionId) {
@@ -1110,18 +1104,15 @@ const SkillAC = (() => {
1110
1104
  input.focus();
1111
1105
  });
1112
1106
 
1113
- // IME composition guard: record timestamp of compositionend so the
1114
- // Enter keydown handler can detect Safari's out-of-order firing.
1115
- $("user-input").addEventListener("compositionend", () => {
1116
- _lastCompositionEndTime = Date.now();
1117
- });
1107
+ // IME composition tracker: shared by main keydown + AC _handleKey.
1108
+ _ime = IME.track($("user-input"));
1118
1109
 
1119
1110
  // Main composer keydown: SkillAC consumes nav keys first, then Enter → send.
1120
1111
  $("user-input").addEventListener("keydown", e => {
1121
1112
  // Let skill autocomplete consume arrow/enter/escape first
1122
1113
  if (_handleKey(e)) return;
1123
1114
 
1124
- if (e.key === "Enter" && !e.shiftKey && !e.isComposing && (Date.now() - _lastCompositionEndTime) > 20) {
1115
+ if (e.key === "Enter" && !e.shiftKey && !_ime.isComposing(e)) {
1125
1116
  e.preventDefault();
1126
1117
  Sessions.sendMessage();
1127
1118
  }
@@ -1173,7 +1164,7 @@ const SkillAC = (() => {
1173
1164
  _select(targetIdx);
1174
1165
  return true;
1175
1166
  }
1176
- if (e.key === "Enter" && !e.isComposing && (Date.now() - _lastCompositionEndTime) > 20) {
1167
+ if (e.key === "Enter" && !_ime.isComposing(e)) {
1177
1168
  if (_activeIndex >= 0) {
1178
1169
  e.preventDefault();
1179
1170
  _select(_activeIndex);
@@ -0,0 +1,57 @@
1
+ // Cross-browser IME composition guard for Enter-to-submit inputs.
2
+ //
3
+ // Problem: pressing Enter to confirm an IME composition (e.g. selecting a
4
+ // Chinese candidate) must NOT trigger submit. Different browsers signal this
5
+ // differently:
6
+ // - Chrome / Firefox / Edge: e.isComposing === true on the confirming Enter
7
+ // - Older browsers: e.keyCode === 229
8
+ // - Safari: fires compositionend ~5ms BEFORE keydown, so isComposing is
9
+ // already false. We need a recent compositionend timestamp to suppress.
10
+ //
11
+ // Reference: https://bugs.webkit.org/show_bug.cgi?id=165004
12
+ //
13
+ // Usage:
14
+ // IME.bindEnter(inputEl, () => submit());
15
+ //
16
+ // Or low-level for handlers that already own a keydown listener:
17
+ // const ime = IME.track(inputEl);
18
+ // inputEl.addEventListener("keydown", e => {
19
+ // if (e.key === "Enter" && !ime.isComposing(e)) submit();
20
+ // });
21
+ const IME = (() => {
22
+ const SAFARI_GUARD_MS = 20;
23
+
24
+ function track(inputEl) {
25
+ let lastCompositionEnd = -Infinity;
26
+ const onEnd = () => { lastCompositionEnd = Date.now(); };
27
+ inputEl.addEventListener("compositionend", onEnd);
28
+
29
+ return {
30
+ isComposing(e) {
31
+ if (e.isComposing || e.keyCode === 229) return true;
32
+ return Date.now() - lastCompositionEnd <= SAFARI_GUARD_MS;
33
+ },
34
+ dispose() {
35
+ inputEl.removeEventListener("compositionend", onEnd);
36
+ }
37
+ };
38
+ }
39
+
40
+ function bindEnter(inputEl, handler, options = {}) {
41
+ const ime = track(inputEl);
42
+ const onKey = (e) => {
43
+ if (e.key !== "Enter") return;
44
+ if (options.allowShift !== true && e.shiftKey) return;
45
+ if (ime.isComposing(e)) return;
46
+ e.preventDefault();
47
+ handler(e);
48
+ };
49
+ inputEl.addEventListener("keydown", onKey);
50
+ return () => {
51
+ inputEl.removeEventListener("keydown", onKey);
52
+ ime.dispose();
53
+ };
54
+ }
55
+
56
+ return { track, bindEnter };
57
+ })();
@@ -16,10 +16,143 @@
16
16
  // Guard: restore hash routing only once after initial session_list arrives.
17
17
  let _initialRestoreDone = false;
18
18
 
19
+ // ── Phase grouping (folds subagent runs like skill evolution) ───────────
20
+ //
21
+ // Strategy: when a phase_start arrives, we append a foldable card to the
22
+ // outer message stream and push its body onto RenderTarget. Sessions.append*
23
+ // resolves its destination via RenderTarget.current(), so subagent events
24
+ // land inside the card. Infrastructure paths (history fetch, empty-hint,
25
+ // scroll, container clear) read RenderTarget.outer() and stay anchored to
26
+ // the real #messages node — phase activity never pollutes them.
27
+ //
28
+ // The DOM id "messages" is never swapped: external code, CSS, devtools and
29
+ // closures all see a stable identity.
30
+ const RenderTarget = (() => {
31
+ const stack = [];
32
+ return {
33
+ push(el) { stack.push(el); },
34
+ pop() { return stack.pop(); },
35
+ current(){ return stack[stack.length - 1] || document.getElementById("messages"); },
36
+ outer() { return document.getElementById("messages"); },
37
+ depth() { return stack.length; },
38
+ };
39
+ })();
40
+ window.RenderTarget = RenderTarget;
41
+
42
+ const _phaseStack = []; // [{ id, kind, card, body, summary }]
43
+
44
+ function _activePhase() {
45
+ return _phaseStack[_phaseStack.length - 1] || null;
46
+ }
47
+
48
+ function _phaseLabel(kind) {
49
+ const map = {
50
+ skill_evolution: I18n.t ? (I18n.t("phase.skill_evolution") || "Skill evolution") : "Skill evolution",
51
+ };
52
+ return map[kind] || kind;
53
+ }
54
+
55
+ function _beginPhase(ev) {
56
+ if (_phaseStack.length > 0) return; // single-level for now
57
+
58
+ const outer = RenderTarget.outer();
59
+ if (!outer) return;
60
+
61
+ const card = document.createElement("details");
62
+ card.className = "msg-phase";
63
+ card.dataset.phaseId = ev.phase_id;
64
+ card.dataset.phaseKind = ev.kind || "phase";
65
+
66
+ const summary = document.createElement("summary");
67
+ summary.className = "msg-phase-summary";
68
+ const labelText = ev.label || _phaseLabel(ev.kind);
69
+ summary.innerHTML = `<span class="msg-phase-icon">🧬</span><span class="msg-phase-label">${escapeHtml(labelText)}</span><span class="msg-phase-status">…</span>`;
70
+ card.appendChild(summary);
71
+
72
+ const body = document.createElement("div");
73
+ body.className = "msg-phase-body";
74
+ card.appendChild(body);
75
+
76
+ outer.appendChild(card);
77
+ RenderTarget.push(body);
78
+
79
+ _phaseStack.push({
80
+ id: ev.phase_id,
81
+ kind: ev.kind,
82
+ card,
83
+ body,
84
+ summary,
85
+ });
86
+
87
+ outer.scrollTop = outer.scrollHeight;
88
+ }
89
+
90
+ function _endPhase(phaseId, summary) {
91
+ const idx = _phaseStack.findIndex(p => p.id === phaseId);
92
+ if (idx === -1) return;
93
+ while (_phaseStack.length > idx) {
94
+ const phase = _phaseStack.pop();
95
+ RenderTarget.pop();
96
+ _finalizePhase(phase, { summary: phase.id === phaseId ? summary : null });
97
+ }
98
+ }
99
+
100
+ function _finalizePhase(phase, { summary, incomplete } = {}) {
101
+ const body = phase.body;
102
+ const isEmpty = !incomplete && body && body.children.length === 0;
103
+ if (isEmpty) phase.card.classList.add("msg-phase-empty");
104
+
105
+ const statusEl = phase.summary && phase.summary.querySelector(".msg-phase-status");
106
+ if (statusEl) {
107
+ if (incomplete) {
108
+ statusEl.textContent = " (interrupted)";
109
+ statusEl.classList.add("msg-phase-status-incomplete");
110
+ } else if (isEmpty) {
111
+ const noChange = I18n.t ? (I18n.t("phase.no_changes") || "no changes needed") : "no changes needed";
112
+ statusEl.textContent = ` ✓ ${noChange}`;
113
+ } else if (summary) {
114
+ statusEl.textContent = ` ✓ ${summary}`;
115
+ } else {
116
+ statusEl.textContent = " ✓";
117
+ }
118
+ }
119
+ }
120
+
121
+ function _closeAllPhases(reason) {
122
+ while (_phaseStack.length > 0) {
123
+ const phase = _phaseStack.pop();
124
+ RenderTarget.pop();
125
+ _finalizePhase(phase, { incomplete: reason === "incomplete" });
126
+ }
127
+ }
128
+
129
+ window._closeAllPhases = _closeAllPhases;
130
+
19
131
 
20
132
  WS.onEvent(ev => {
133
+ // Safety nets:
134
+ // - User just sent a message → any open phase is stale, close it.
135
+ // - Session changed → phase belongs to the previous session, close it.
136
+ if (ev.type === "history_user_message" || ev.type === "subscribed") {
137
+ _closeAllPhases("incomplete");
138
+ }
139
+
21
140
  switch (ev.type) {
22
141
 
142
+ // ── Phase grouping ─────────────────────────────────────────────────
143
+ case "phase_start": {
144
+ if (ev.session_id !== Sessions.activeId) break;
145
+ _beginPhase(ev);
146
+ break;
147
+ }
148
+
149
+ case "phase_end": {
150
+ if (ev.session_id !== Sessions.activeId) break;
151
+ _endPhase(ev.phase_id, ev.summary);
152
+ break;
153
+ }
154
+
155
+
23
156
  // ── Internal WS lifecycle ──────────────────────────────────────────
24
157
  case "_ws_connected": {
25
158
  const banner = document.getElementById("offline-banner");
@@ -41,6 +174,7 @@ WS.onEvent(ev => {
41
174
  // caused stuck UI after reconnect when the snapshot logic wasn't
42
175
  // re-asserting status on every reconnect.
43
176
  Sessions.clearAllProgress();
177
+ _closeAllPhases("incomplete");
44
178
  break;
45
179
  }
46
180
 
@@ -207,6 +341,7 @@ WS.onEvent(ev => {
207
341
  if (ev.session_id !== Sessions.activeId) break;
208
342
  Sessions.clearProgress();
209
343
  Sessions.collapseToolGroup();
344
+ _closeAllPhases("incomplete"); // safety net: missed phase_end
210
345
  {
211
346
  const costSource = ev.cost_source;
212
347
  const symbol = typeof Billing !== "undefined" ? Billing.getCurrencySymbol() : "$";
@@ -251,6 +386,7 @@ WS.onEvent(ev => {
251
386
  if (ev.session_id !== Sessions.activeId) break;
252
387
  Sessions.clearProgress();
253
388
  Sessions.collapseToolGroup();
389
+ _closeAllPhases("incomplete");
254
390
  Sessions.appendInfo(I18n.t("chat.interrupted"));
255
391
  break;
256
392
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.12
4
+ version: 1.2.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-05 00:00:00.000000000 Z
11
+ date: 2026-06-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -387,6 +387,7 @@ files:
387
387
  - lib/clacky/default_skills/personal-website/publish.rb
388
388
  - lib/clacky/default_skills/product-help/SKILL.md
389
389
  - lib/clacky/default_skills/recall-memory/SKILL.md
390
+ - lib/clacky/default_skills/search-skills/SKILL.md
390
391
  - lib/clacky/default_skills/skill-add/SKILL.md
391
392
  - lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb
392
393
  - lib/clacky/default_skills/skill-creator/SKILL.md
@@ -558,6 +559,7 @@ files:
558
559
  - lib/clacky/web/tasks.js
559
560
  - lib/clacky/web/theme.js
560
561
  - lib/clacky/web/trash.js
562
+ - lib/clacky/web/utils.js
561
563
  - lib/clacky/web/vendor/hljs/highlight.min.js
562
564
  - lib/clacky/web/vendor/hljs/hljs-theme.css
563
565
  - lib/clacky/web/vendor/katex/auto-render.min.js