openclacky 1.2.12 → 1.2.14

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +5 -1
  3. data/.clacky/skills/gem-release/scripts/release.sh +4 -1
  4. data/CHANGELOG.md +39 -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/session_serializer.rb +1 -0
  8. data/lib/clacky/agent/skill_auto_creator.rb +7 -4
  9. data/lib/clacky/agent/skill_evolution.rb +23 -5
  10. data/lib/clacky/agent/skill_manager.rb +86 -1
  11. data/lib/clacky/agent/skill_reflector.rb +18 -23
  12. data/lib/clacky/agent.rb +132 -15
  13. data/lib/clacky/agent_config.rb +183 -22
  14. data/lib/clacky/cli.rb +55 -0
  15. data/lib/clacky/client.rb +11 -1
  16. data/lib/clacky/default_parsers/pdf_parser.rb +70 -86
  17. data/lib/clacky/default_parsers/pdf_parser_vlm.py +136 -0
  18. data/lib/clacky/default_skills/persist-memory/SKILL.md +4 -3
  19. data/lib/clacky/default_skills/search-skills/SKILL.md +61 -0
  20. data/lib/clacky/idle_compression_timer.rb +1 -1
  21. data/lib/clacky/message_format/open_ai.rb +7 -1
  22. data/lib/clacky/openai_stream_aggregator.rb +4 -1
  23. data/lib/clacky/providers.rb +77 -12
  24. data/lib/clacky/server/http_server.rb +296 -7
  25. data/lib/clacky/server/session_registry.rb +30 -8
  26. data/lib/clacky/server/web_ui_controller.rb +24 -1
  27. data/lib/clacky/session_manager.rb +120 -0
  28. data/lib/clacky/tools/web_search.rb +59 -8
  29. data/lib/clacky/ui2/layout_manager.rb +15 -5
  30. data/lib/clacky/ui2/progress_handle.rb +18 -8
  31. data/lib/clacky/ui2/ui_controller.rb +27 -0
  32. data/lib/clacky/ui_interface.rb +22 -0
  33. data/lib/clacky/utils/model_pricing.rb +96 -0
  34. data/lib/clacky/version.rb +1 -1
  35. data/lib/clacky/vision/resolver.rb +157 -0
  36. data/lib/clacky/web/app.css +209 -4
  37. data/lib/clacky/web/app.js +6 -5
  38. data/lib/clacky/web/i18n.js +22 -6
  39. data/lib/clacky/web/index.html +2 -1
  40. data/lib/clacky/web/sessions.js +408 -80
  41. data/lib/clacky/web/settings.js +241 -60
  42. data/lib/clacky/web/skills.js +5 -14
  43. data/lib/clacky/web/utils.js +57 -0
  44. data/lib/clacky/web/ws-dispatcher.js +136 -0
  45. data/lib/clacky.rb +1 -0
  46. metadata +6 -2
@@ -1528,7 +1528,7 @@ const Settings = (() => {
1528
1528
  // The state object per kind:
1529
1529
  // { source, configured, model, base_url, api_key_masked, provider, available }
1530
1530
 
1531
- const MEDIA_KINDS = ["image", "video", "audio"];
1531
+ const MEDIA_KINDS = ["image", "video", "audio", "ocr"];
1532
1532
  let _mediaState = null;
1533
1533
  let _mediaDefaults = null;
1534
1534
  const _mediaCustomDraft = {};
@@ -1538,10 +1538,16 @@ const Settings = (() => {
1538
1538
  if (!container) return;
1539
1539
  container.innerHTML = `<div class="settings-loading">${I18n.t("settings.media.loading")}</div>`;
1540
1540
  try {
1541
- const res = await fetch("/api/config/media");
1542
- const data = await res.json();
1543
- _mediaState = data.media || {};
1544
- _mediaDefaults = data.default_provider || {};
1541
+ const [mediaRes, ocrRes] = await Promise.all([
1542
+ fetch("/api/config/media"),
1543
+ fetch("/api/config/ocr")
1544
+ ]);
1545
+ const mediaData = await mediaRes.json();
1546
+ const ocrData = await ocrRes.json();
1547
+ _mediaState = mediaData.media || {};
1548
+ _mediaDefaults = mediaData.default_provider || {};
1549
+ _mediaState["ocr"] = ocrData.ocr || { source: "off", available: [] };
1550
+ _mediaDefaults["ocr"] = ocrData.default_provider || { available: [] };
1545
1551
  _renderMediaRows();
1546
1552
  } catch (e) {
1547
1553
  container.innerHTML = `<div class="settings-error">${I18n.t("settings.media.error", { msg: e.message })}</div>`;
@@ -1557,6 +1563,14 @@ const Settings = (() => {
1557
1563
  });
1558
1564
  }
1559
1565
 
1566
+ function _refreshKindRows(_kind) {
1567
+ _renderMediaRows();
1568
+ }
1569
+
1570
+ async function _reloadKind(_kind) {
1571
+ await _loadMedia();
1572
+ }
1573
+
1560
1574
  function _renderMediaRow(kind) {
1561
1575
  const state = (_mediaState && _mediaState[kind]) || { source: "off", available: [] };
1562
1576
  const def = (_mediaDefaults && _mediaDefaults[kind]) || { available: [] };
@@ -1616,9 +1630,6 @@ const Settings = (() => {
1616
1630
  if (state.configured && state.model) return state.model;
1617
1631
  return "";
1618
1632
  }
1619
- if (state.source === "custom" && state.configured && !_mediaCustomDraft[kind]) {
1620
- return state.model || "";
1621
- }
1622
1633
  return "";
1623
1634
  }
1624
1635
 
@@ -1629,15 +1640,74 @@ const Settings = (() => {
1629
1640
  if (state.configured && state.model) {
1630
1641
  const wrap = document.createElement("div");
1631
1642
  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
- `;
1643
+
1644
+ const providerLine = document.createElement("div");
1645
+ providerLine.className = "media-provider-line";
1646
+ const pLabel = document.createElement("span");
1647
+ pLabel.className = "media-provider-label";
1648
+ pLabel.textContent = I18n.t("settings.media.field.provider");
1649
+ const pVal = document.createElement("span");
1650
+ pVal.className = "media-provider-value";
1651
+ pVal.textContent = state.provider || "—";
1652
+ providerLine.appendChild(pLabel);
1653
+ providerLine.appendChild(pVal);
1654
+ wrap.appendChild(providerLine);
1655
+
1656
+ const modelField = _buildField(I18n.t("settings.media.field.model"));
1657
+ const opts = Array.isArray(state.available) ? state.available : [];
1658
+ const aliases = (state.aliases && typeof state.aliases === "object") ? state.aliases : {};
1659
+ const labelOf = (m) => aliases[m] ? `${m} (${aliases[m]})` : m;
1660
+ if (opts.length > 1) {
1661
+ const sel = document.createElement("select");
1662
+ sel.className = "field-select";
1663
+ opts.forEach(m => {
1664
+ const o = document.createElement("option");
1665
+ o.value = m;
1666
+ o.textContent = labelOf(m);
1667
+ if (m === state.model) o.selected = true;
1668
+ sel.appendChild(o);
1669
+ });
1670
+ sel.addEventListener("change", async () => {
1671
+ const picked = sel.value;
1672
+ const payload = picked === (def && def.model)
1673
+ ? { source: "auto" }
1674
+ : { source: "auto", model: picked };
1675
+ sel.disabled = true;
1676
+ _setMediaResult(kind, "testing", I18n.t("settings.media.action.saving"));
1677
+ try {
1678
+ await _saveMediaConfig(kind, payload);
1679
+ await _reloadKind(kind);
1680
+ } catch (e) {
1681
+ sel.disabled = false;
1682
+ _setMediaResult(kind, "fail", e.message);
1683
+ }
1684
+ });
1685
+ modelField.appendChild(sel);
1686
+ } else {
1687
+ const ro = document.createElement("div");
1688
+ ro.className = "media-auto-readonly";
1689
+ ro.textContent = labelOf(state.model);
1690
+ modelField.appendChild(ro);
1691
+ }
1692
+ wrap.appendChild(modelField);
1693
+
1694
+ if (state.stale && state.requested_model) {
1695
+ const warn = document.createElement("div");
1696
+ warn.className = "media-row-hint is-warning";
1697
+ warn.textContent = I18n.t("settings.media.auto.stale", {
1698
+ requested: labelOf(state.requested_model),
1699
+ current: labelOf(state.model)
1700
+ });
1701
+ wrap.appendChild(warn);
1702
+ } else {
1703
+ const hint = document.createElement("div");
1704
+ hint.className = "media-row-hint";
1705
+ hint.textContent = I18n.t("settings.media.auto.followsDefault");
1706
+ wrap.appendChild(hint);
1707
+ }
1708
+
1709
+ wrap.appendChild(_buildMediaResult(kind));
1710
+
1641
1711
  return wrap;
1642
1712
  }
1643
1713
  const wrap = document.createElement("div");
@@ -1651,21 +1721,34 @@ const Settings = (() => {
1651
1721
  if (state.configured && !_mediaCustomDraft[kind]) {
1652
1722
  const wrap = document.createElement("div");
1653
1723
  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
- `;
1724
+
1725
+ const list = document.createElement("div");
1726
+ list.className = "media-custom-readonly-list";
1727
+ [
1728
+ ["settings.media.field.model", state.model],
1729
+ ["settings.media.field.baseUrl", state.base_url],
1730
+ ["settings.media.field.apiKey", state.api_key_masked]
1731
+ ].forEach(([lk, v]) => {
1732
+ const row = document.createElement("div");
1733
+ row.className = "media-custom-readonly-row";
1734
+ const lbl = document.createElement("span");
1735
+ lbl.className = "media-custom-readonly-label";
1736
+ lbl.textContent = I18n.t(lk);
1737
+ const val = document.createElement("span");
1738
+ val.className = "media-custom-readonly-value";
1739
+ val.textContent = v || "—";
1740
+ row.appendChild(lbl);
1741
+ row.appendChild(val);
1742
+ list.appendChild(row);
1743
+ });
1744
+ wrap.appendChild(list);
1745
+
1664
1746
  const actions = document.createElement("div");
1665
1747
  actions.className = "media-row-actions";
1748
+
1666
1749
  const editBtn = document.createElement("button");
1667
1750
  editBtn.type = "button";
1668
- editBtn.className = "media-row-btn";
1751
+ editBtn.className = "btn-secondary media-row-btn-sm";
1669
1752
  editBtn.textContent = I18n.t("settings.media.action.edit");
1670
1753
  editBtn.addEventListener("click", () => {
1671
1754
  _mediaCustomDraft[kind] = {
@@ -1673,10 +1756,35 @@ const Settings = (() => {
1673
1756
  base_url: state.base_url || "",
1674
1757
  api_key: ""
1675
1758
  };
1676
- _renderMediaRows();
1759
+ _refreshKindRows(kind);
1677
1760
  });
1761
+
1762
+ const testBtn = document.createElement("button");
1763
+ testBtn.type = "button";
1764
+ testBtn.className = "btn-secondary media-row-btn-sm";
1765
+ testBtn.textContent = I18n.t("settings.media.action.test");
1766
+ testBtn.addEventListener("click", async () => {
1767
+ testBtn.disabled = true;
1768
+ editBtn.disabled = true;
1769
+ _setMediaResult(kind, "testing", I18n.t("settings.media.testing"));
1770
+ try {
1771
+ const r = await _testMediaConfig(kind, {
1772
+ model: state.model,
1773
+ base_url: state.base_url,
1774
+ api_key: state.api_key_masked
1775
+ });
1776
+ _setMediaResult(kind, r.ok ? "ok" : "fail", r.message || "");
1777
+ } finally {
1778
+ testBtn.disabled = false;
1779
+ editBtn.disabled = false;
1780
+ }
1781
+ });
1782
+
1783
+ actions.appendChild(testBtn);
1678
1784
  actions.appendChild(editBtn);
1679
1785
  wrap.appendChild(actions);
1786
+
1787
+ wrap.appendChild(_buildMediaResult(kind));
1680
1788
  return wrap;
1681
1789
  }
1682
1790
 
@@ -1684,37 +1792,58 @@ const Settings = (() => {
1684
1792
  const draft = _mediaCustomDraft[kind] || { model: "", base_url: "", api_key: "" };
1685
1793
  const wrap = document.createElement("div");
1686
1794
  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 => {
1795
+
1796
+ const fields = document.createElement("div");
1797
+ fields.className = "media-custom-fields";
1798
+
1799
+ const mkInput = (labelKey, fieldName, type, placeholder, initial) => {
1800
+ const f = _buildField(I18n.t(labelKey));
1801
+ const inp = document.createElement("input");
1802
+ inp.type = type;
1803
+ inp.className = "field-input";
1804
+ inp.value = initial || "";
1805
+ inp.placeholder = placeholder;
1806
+ inp.dataset.field = fieldName;
1702
1807
  inp.addEventListener("input", () => {
1703
1808
  _mediaCustomDraft[kind] = _mediaCustomDraft[kind] || {};
1704
- _mediaCustomDraft[kind][inp.dataset.field] = inp.value;
1809
+ _mediaCustomDraft[kind][fieldName] = inp.value;
1705
1810
  });
1706
- });
1707
- form.querySelector('[data-act="cancel"]').addEventListener("click", () => {
1811
+ f.appendChild(inp);
1812
+ return f;
1813
+ };
1814
+ fields.appendChild(mkInput("settings.media.field.model", "model", "text", "gpt-image-1", draft.model));
1815
+ fields.appendChild(mkInput("settings.media.field.baseUrl", "base_url", "text", "https://api.openai.com/v1", draft.base_url));
1816
+ fields.appendChild(mkInput("settings.media.field.apiKey", "api_key", "password", I18n.t("settings.media.apiKey.placeholder"), draft.api_key));
1817
+ wrap.appendChild(fields);
1818
+
1819
+ const result = _buildMediaResult(kind);
1820
+ wrap.appendChild(result);
1821
+
1822
+ const actions = document.createElement("div");
1823
+ actions.className = "media-row-actions";
1824
+
1825
+ const cancelBtn = document.createElement("button");
1826
+ cancelBtn.type = "button";
1827
+ cancelBtn.className = "btn-secondary media-row-btn-sm";
1828
+ cancelBtn.textContent = I18n.t("settings.media.action.cancel");
1829
+ cancelBtn.addEventListener("click", () => {
1708
1830
  delete _mediaCustomDraft[kind];
1709
- // If there's no saved custom, fall back to whatever the saved source is (or off)
1710
1831
  if (!state.configured) {
1711
1832
  const fallback = (_mediaDefaults && _mediaDefaults[kind] && _mediaDefaults[kind].model) ? "auto" : "off";
1712
1833
  _mediaState[kind] = { ..._mediaState[kind], source: fallback };
1713
1834
  }
1714
- _renderMediaRows();
1835
+ _refreshKindRows(kind);
1715
1836
  });
1716
- form.querySelector('[data-act="save"]').addEventListener("click", async () => {
1837
+
1838
+ const saveBtn = document.createElement("button");
1839
+ saveBtn.type = "button";
1840
+ saveBtn.className = "btn-primary media-row-btn-sm";
1841
+ saveBtn.textContent = I18n.t("settings.media.action.save");
1842
+ saveBtn.addEventListener("click", async () => {
1717
1843
  const d = _mediaCustomDraft[kind] || {};
1844
+ saveBtn.disabled = true;
1845
+ cancelBtn.disabled = true;
1846
+ _setMediaResult(kind, "testing", I18n.t("settings.media.action.saving"));
1718
1847
  try {
1719
1848
  await _saveMediaConfig(kind, {
1720
1849
  source: "custom",
@@ -1723,26 +1852,57 @@ const Settings = (() => {
1723
1852
  api_key: d.api_key || ""
1724
1853
  });
1725
1854
  delete _mediaCustomDraft[kind];
1726
- await _loadMedia();
1855
+ await _reloadKind(kind);
1727
1856
  } catch (e) {
1728
- alert(e.message);
1857
+ saveBtn.disabled = false;
1858
+ cancelBtn.disabled = false;
1859
+ _setMediaResult(kind, "fail", e.message);
1729
1860
  }
1730
1861
  });
1731
- wrap.appendChild(form);
1862
+
1863
+ actions.appendChild(cancelBtn);
1864
+ actions.appendChild(saveBtn);
1865
+ wrap.appendChild(actions);
1866
+
1732
1867
  return wrap;
1733
1868
  }
1734
1869
 
1870
+ function _buildField(labelText) {
1871
+ const f = document.createElement("div");
1872
+ f.className = "model-field";
1873
+ const label = document.createElement("span");
1874
+ label.className = "field-label";
1875
+ label.textContent = labelText;
1876
+ f.appendChild(label);
1877
+ return f;
1878
+ }
1879
+
1880
+ function _buildMediaResult(kind) {
1881
+ const el = document.createElement("div");
1882
+ el.className = "model-test-result";
1883
+ el.dataset.mediaKind = kind;
1884
+ return el;
1885
+ }
1886
+
1887
+ function _setMediaResult(kind, status, message) {
1888
+ const el = document.querySelector(`.model-test-result[data-media-kind="${kind}"]`);
1889
+ if (!el) return;
1890
+ el.className = `model-test-result result-${status}`;
1891
+ if (!message) {
1892
+ el.textContent = "";
1893
+ return;
1894
+ }
1895
+ const prefix = status === "ok" ? "✓ " : status === "fail" ? "✗ " : "";
1896
+ el.textContent = prefix + message;
1897
+ }
1898
+
1735
1899
  async function _onMediaSourceClick(kind, source) {
1736
1900
  const cur = (_mediaState && _mediaState[kind]) || {};
1737
1901
  if (cur.source === source && source !== "custom") return;
1738
1902
 
1739
1903
  if (source === "custom") {
1740
1904
  if (cur.source !== "custom" && !_mediaCustomDraft[kind]) {
1741
- _mediaCustomDraft[kind] = {
1742
- model: cur.model || "",
1743
- base_url: cur.base_url || "",
1744
- api_key: ""
1745
- };
1905
+ _mediaCustomDraft[kind] = { model: "", base_url: "", api_key: "" };
1746
1906
  }
1747
1907
  _mediaState[kind] = { ...cur, source: "custom" };
1748
1908
  _renderMediaRows();
@@ -1754,12 +1914,14 @@ const Settings = (() => {
1754
1914
  delete _mediaCustomDraft[kind];
1755
1915
  await _loadMedia();
1756
1916
  } catch (e) {
1757
- alert(e.message);
1917
+ _renderMediaRows();
1918
+ _setMediaResult(kind, "fail", e.message);
1758
1919
  }
1759
1920
  }
1760
1921
 
1761
1922
  async function _saveMediaConfig(kind, body) {
1762
- const res = await fetch(`/api/config/media/${kind}`, {
1923
+ const url = kind === "ocr" ? `/api/config/ocr` : `/api/config/media/${kind}`;
1924
+ const res = await fetch(url, {
1763
1925
  method: "PATCH",
1764
1926
  headers: { "Content-Type": "application/json" },
1765
1927
  body: JSON.stringify(body)
@@ -1771,6 +1933,25 @@ const Settings = (() => {
1771
1933
  return data;
1772
1934
  }
1773
1935
 
1936
+ async function _testMediaConfig(kind, { model, base_url, api_key }) {
1937
+ try {
1938
+ const url = kind === "ocr" ? `/api/config/ocr/test` : `/api/config/media/test`;
1939
+ const payload = kind === "ocr"
1940
+ ? { model, base_url, api_key }
1941
+ : { kind, model, base_url, api_key };
1942
+ const res = await fetch(url, {
1943
+ method: "POST",
1944
+ headers: { "Content-Type": "application/json" },
1945
+ body: JSON.stringify(payload)
1946
+ });
1947
+ const data = await res.json().catch(() => ({}));
1948
+ if (!res.ok) return { ok: false, message: data.error || `HTTP ${res.status}` };
1949
+ return data;
1950
+ } catch (e) {
1951
+ return { ok: false, message: e.message };
1952
+ }
1953
+ }
1954
+
1774
1955
 
1775
1956
  function init() {
1776
1957
  _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