openclacky 1.1.4 → 1.1.6

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4107ac9d894f9af9647f19152855f13854d4cfebd8013d2d22057ca98de87274
4
- data.tar.gz: 3c86e0b865cccc27f8c785f39c49384744ad4e6cce6cdf257ca0485f1d62c260
3
+ metadata.gz: d07cc1b558deca4a0bcbbd49133b1e75541437e576811fff6848ea205a1df9e2
4
+ data.tar.gz: 547e9b2215f53f41902fdfd5f03c99e0f3fd1d532967279c9eb83f62a57d0a0a
5
5
  SHA512:
6
- metadata.gz: e6d66fd62f6434c05420f5f639a412fb953e49634c3f0740f155a2d20b70d91acb12a9844a61330f0761d88ff2c284a1d071f0590b3e53553bd11d57a035669f
7
- data.tar.gz: c64f138d54a5397ba0a49b6878b79576f12b6fc2dec05f7e4b24b5eaf3490060743a0e3a20da17b6cf47476a85eaa093742b616b5be9ab65859db6e8db035404
6
+ metadata.gz: 642a482e321f6b4c178143bf7a18e296fd7dcab5e4e10b8ac00676a007e54e972a5bb9ec036932e609e759ed4c946c2bf9d772ed91a15e9561e0c4b68c6b6a0b
7
+ data.tar.gz: cdd52c0b1d081a9a1931b4ed96d88e780497b44180a37150bce2c1b7d352051c8cf9cf5d973a851665ed9eef5c1e6ce572deb829a41e41ca3f7b2f8791e62c9f
data/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.1.6] - 2026-05-22
9
+
10
+ ### Added
11
+ - Fold cron sessions into a collapsible group in session list sidebar
12
+
13
+ ### Fixed
14
+ - Free skill hints display issue
15
+
16
+ ## [1.1.5] - 2026-05-22
17
+
18
+ ### Fixed
19
+ - Async free skills handling
20
+
8
21
  ## [1.1.4] - 2026-05-22
9
22
 
10
23
  ### Added
data/lib/clacky/cli.rb CHANGED
@@ -338,25 +338,16 @@ module Clacky
338
338
 
339
339
  # ── Brand license check (CLI mode) ──────────────────────────────────────
340
340
  #
341
- # Called at the start of run_agent_with_ui2, before UI2 raw mode begins.
342
- # Uses Thor's say + tty-prompt for interaction (both are existing dependencies).
343
- #
344
- # Flow:
345
- # not branded -> skip (standard OpenClacky experience)
346
- # branded, no key -> prompt for license key and activate
347
- # branded, expired -> warn and continue
348
- # branded, active -> send heartbeat if interval elapsed (once per day)
341
+ # CLI is a developer-oriented entrypoint: we never block startup with an
342
+ # interactive license prompt. Unactivated installs run in free mode; the
343
+ # WebUI is where end-users activate. This method only surfaces non-blocking
344
+ # warnings (expiry, offline grace period) and dispatches async heartbeats.
349
345
  private def check_brand_license_cli
350
346
  brand = Clacky::BrandConfig.load
351
347
  return unless brand.branded?
348
+ return unless brand.activated?
352
349
 
353
- Clacky::Logger.info("[Brand] check_brand_license_cli: activated=#{brand.activated?} expired=#{brand.expired?} expires_at=#{brand.license_expires_at&.iso8601 || "nil"} last_heartbeat=#{brand.license_last_heartbeat&.iso8601 || "nil"}")
354
-
355
- unless brand.activated?
356
- Clacky::Logger.info("[Brand] check_brand_license_cli: not activated, prompting user")
357
- cli_prompt_license_activation(brand)
358
- return
359
- end
350
+ Clacky::Logger.info("[Brand] check_brand_license_cli: activated=true expired=#{brand.expired?} expires_at=#{brand.license_expires_at&.iso8601 || "nil"} last_heartbeat=#{brand.license_last_heartbeat&.iso8601 || "nil"}")
360
351
 
361
352
  if brand.expired?
362
353
  Clacky::Logger.warn("[Brand] check_brand_license_cli: license expired at #{brand.license_expires_at&.iso8601}")
@@ -366,11 +357,6 @@ module Clacky
366
357
  return
367
358
  end
368
359
 
369
- # Heartbeat is fire-and-forget — startup must never block on the
370
- # license server. The grace_period_exceeded? check below now keys off
371
- # license_last_heartbeat_failure (set on a failed heartbeat, cleared
372
- # on success), so a user who simply hasn't run the app for >3 days
373
- # no longer sees a false "offline" warning on first launch.
374
360
  if brand.heartbeat_due?
375
361
  Clacky::Logger.info("[Brand] check_brand_license_cli: heartbeat due, dispatching async...")
376
362
  Thread.new do
@@ -397,44 +383,6 @@ module Clacky
397
383
  end
398
384
  end
399
385
 
400
- # Interactive license key prompt using tty-prompt.
401
- private def cli_prompt_license_activation(brand)
402
- prompt = TTY::Prompt.new
403
-
404
- say ""
405
- say "Welcome to #{brand.product_name}!", :cyan
406
- say "A license key is required to activate this installation."
407
- say ""
408
-
409
- loop do
410
- key = prompt.ask("Enter your license key (XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX):",
411
- required: false) { |q| q.modify :strip }
412
-
413
- if key.nil? || key.empty?
414
- say "No key entered. You can activate later by re-launching.", :yellow
415
- say ""
416
- return
417
- end
418
-
419
- unless key.match?(/\A[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{8}){4}\z/)
420
- say "Invalid key format. Expected: XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX", :red
421
- next
422
- end
423
-
424
- say "Activating..."
425
- result = brand.activate!(key)
426
-
427
- if result[:success]
428
- say result[:message], :green
429
- say ""
430
- return
431
- else
432
- say result[:message], :red
433
- say "(Press Enter to skip activation.)"
434
- end
435
- end
436
- end
437
-
438
386
  CLI_DEFAULT_SESSION_NAME = "CLI Session"
439
387
 
440
388
  # Auto-name a CLI session from the first user message, mirroring server-side logic.
@@ -527,7 +527,7 @@ module Clacky
527
527
  non_pinned_part = non_pinned_part.first(limit)
528
528
  sessions = pinned_part + non_pinned_part
529
529
 
530
- json_response(res, 200, { sessions: sessions, has_more: has_more })
530
+ json_response(res, 200, { sessions: sessions, has_more: has_more, cron_count: @registry.cron_count })
531
531
  end
532
532
 
533
533
  def api_create_session(req, res)
@@ -803,30 +803,12 @@ module Clacky
803
803
  refresh_pending = true
804
804
  end
805
805
 
806
- # Free-mode counts: synchronous fetch is acceptable here because
807
- # this endpoint is polled lazily and the platform call is cached
808
- # via http keep-alive. On error we just return zero counts and the
809
- # banner falls back to the legacy "not activated" message.
810
- free_count = 0
811
- paid_count = 0
812
- begin
813
- result = brand.fetch_free_skills!
814
- if result[:success]
815
- free_count = result[:skills].size
816
- paid_count = result[:paid_skills_count].to_i
817
- end
818
- rescue StandardError
819
- # Network errors are non-fatal here.
820
- end
821
-
822
806
  json_response(res, 200, {
823
807
  branded: true,
824
808
  needs_activation: true,
825
809
  product_name: brand.product_name,
826
810
  test_mode: @brand_test,
827
- distribution_refresh_pending: refresh_pending,
828
- free_skills_count: free_count,
829
- paid_skills_count: paid_count
811
+ distribution_refresh_pending: refresh_pending
830
812
  })
831
813
  return
832
814
  end
@@ -3441,7 +3423,7 @@ module Clacky
3441
3423
  page = @registry.list(limit: 21)
3442
3424
  has_more = page.size > 20
3443
3425
  all_sessions = page.first(20)
3444
- conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more)
3426
+ conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more, cron_count: @registry.cron_count)
3445
3427
 
3446
3428
  when "run_task"
3447
3429
  # Client sends this after subscribing to guarantee it's ready to receive
@@ -288,6 +288,12 @@ module Clacky
288
288
 
289
289
  public
290
290
 
291
+ # Count all cron sessions on disk (not filtered by pagination).
292
+ def cron_count
293
+ return 0 unless @session_manager
294
+ @session_manager.all_sessions.count { |s| s_source(s) == "cron" }
295
+ end
296
+
291
297
  # Delete a session from registry (and interrupt its thread).
292
298
  def delete(session_id)
293
299
  @mutex.synchronize do
@@ -74,9 +74,11 @@ module Clacky
74
74
  # own (editable, up-to-date) copy rather than the encrypted distribution copy.
75
75
  # @return [Array<Skill>]
76
76
  def load_brand_skills
77
- return [] unless @brand_config&.activated?
77
+ return [] unless @brand_config && (@brand_config.branded? || @brand_config.activated?)
78
78
  return [] if ENV["CLACKY_TEST"] == "1"
79
79
 
80
+ activated = @brand_config.activated?
81
+
80
82
  # Use brand_config#brand_skills_dir so the path respects CONFIG_DIR,
81
83
  # which is important for test isolation via stub_const.
82
84
  brand_skills_dir = Pathname.new(@brand_config.brand_skills_dir)
@@ -93,6 +95,8 @@ module Clacky
93
95
  plain = skill_dir.join("SKILL.md").exist?
94
96
  next unless encrypted || plain
95
97
 
98
+ next if encrypted && !activated
99
+
96
100
  skill_name = skill_dir.basename.to_s
97
101
 
98
102
  # Skip brand skill when a local plain skill with the same name is already
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.1.4"
4
+ VERSION = "1.1.6"
5
5
  end
@@ -37,14 +37,7 @@ const Brand = (() => {
37
37
  // so no DOM update is needed here on boot.
38
38
 
39
39
  if (data.needs_activation) {
40
- // Show a top banner instead of a blocking full-screen panel.
41
- // Boot continues normally; user can activate at any time via the banner.
42
- _showActivationBanner(data.product_name, data.free_skills_count, data.paid_skills_count);
43
-
44
- // Apply logo/theme from whatever is already cached in brand.yml —
45
- // install.sh only writes product_name + package_name, but if a
46
- // previous session already pulled the public distribution info,
47
- // we can light up the full brand visuals right now.
40
+ _showActivationBanner(data.product_name);
48
41
  _applyHeaderLogo();
49
42
 
50
43
  // Backend just kicked off an async refresh of the public distribution
@@ -75,42 +68,64 @@ const Brand = (() => {
75
68
  // ── Internal ───────────────────────────────────────────────────────────────
76
69
 
77
70
  // Show a dismissible activation banner at the top of the page.
78
- // Clicking the banner creates a dedicated session and invokes the
79
- // Clicking the banner opens Settings and focuses the license key input directly.
80
- function _showActivationBanner(brandName, freeCount, paidCount) {
81
- const existing = document.getElementById("brand-activation-banner");
82
- if (existing) return;
71
+ // Defers rendering until /api/brand/skills resolves so the banner shows its
72
+ // final copy in one shot. Falls back to a generic prompt if the API fails
73
+ // or stays silent for 5s.
74
+ function _showActivationBanner(brandName) {
75
+ if (document.getElementById("brand-activation-banner")) return;
76
+
77
+ const name = brandName || I18n.t("brand.banner.defaultName");
78
+
79
+ let settled = false;
80
+ const settle = data => {
81
+ if (settled) return;
82
+ settled = true;
83
+ if (document.getElementById("brand-activation-banner")) return;
84
+ _renderActivationBanner(name, data);
85
+ };
83
86
 
87
+ fetch("/api/brand/skills")
88
+ .then(r => r.json())
89
+ .then(settle)
90
+ .catch(() => settle(null));
91
+
92
+ setTimeout(() => settle(null), 5000);
93
+ }
94
+
95
+ function _renderActivationBanner(name, countsData) {
84
96
  const bar = document.createElement("div");
85
97
  bar.id = "brand-activation-banner";
86
98
  bar.className = "brand-activation-banner";
87
99
 
88
100
  const span = document.createElement("span");
89
- const name = brandName || I18n.t("brand.banner.defaultName");
90
- const free = Number(freeCount) || 0;
91
- const paid = Number(paidCount) || 0;
101
+ const link = document.createElement("button");
102
+ link.className = "brand-activation-banner-link";
103
+ link.addEventListener("click", () => _goToLicenseInput());
104
+
105
+ let i18nKey = "brand.banner.prompt";
106
+ let vars = { name };
107
+ let hideLink = false;
92
108
 
93
- let i18nKey;
94
- if (free > 0 && paid > 0) i18nKey = "brand.banner.freePromptBoth";
95
- else if (free > 0 && paid === 0) i18nKey = "brand.banner.freePromptOnlyFree";
96
- else if (free === 0 && paid > 0) i18nKey = "brand.banner.freePromptOnlyPaid";
97
- else i18nKey = "brand.banner.prompt";
109
+ if (countsData && countsData.ok && countsData.free_mode) {
110
+ const free = (countsData.skills || []).length;
111
+ const paid = Number(countsData.paid_skills_count) || 0;
112
+ vars = { name, free, paid, freePlural: free === 1 ? "" : "s", paidPlural: paid === 1 ? "" : "s" };
113
+
114
+ if (free > 0 && paid > 0) i18nKey = "brand.banner.freePromptBoth";
115
+ else if (free > 0 && paid === 0) { i18nKey = "brand.banner.freePromptOnlyFree"; hideLink = true; }
116
+ else if (free === 0 && paid > 0) i18nKey = "brand.banner.freePromptOnlyPaid";
117
+ }
98
118
 
99
- const vars = { name, free, paid, freePlural: free === 1 ? "" : "s", paidPlural: paid === 1 ? "" : "s" };
100
119
  span.textContent = I18n.t(i18nKey, vars);
101
120
  span.setAttribute("data-i18n", i18nKey);
102
- span.setAttribute("data-i18n-vars", `name=${name};free=${free};paid=${paid};freePlural=${vars.freePlural};paidPlural=${vars.paidPlural}`);
121
+ span.setAttribute(
122
+ "data-i18n-vars",
123
+ Object.entries(vars).map(([k, v]) => `${k}=${v}`).join(";")
124
+ );
103
125
 
104
- const link = document.createElement("button");
105
- link.className = "brand-activation-banner-link";
106
126
  link.textContent = I18n.t("brand.banner.action");
107
127
  link.setAttribute("data-i18n", "brand.banner.action");
108
- link.addEventListener("click", () => _goToLicenseInput());
109
-
110
- // Hide the "Activate Now" button when there is nothing premium to unlock.
111
- if (paid === 0 && free > 0) {
112
- link.style.display = "none";
113
- }
128
+ if (hideLink) link.style.display = "none";
114
129
 
115
130
  const closeBtn = document.createElement("button");
116
131
  closeBtn.className = "brand-activation-banner-close";
@@ -88,6 +88,9 @@ const I18n = (() => {
88
88
  "sib.reasoning.high": "High",
89
89
  "sessions.thinking": "Thinking…",
90
90
  "sessions.default_name": "Session {{n}}",
91
+ "sessions.cronGroup": "Scheduled Tasks",
92
+ "sessions.cronGroupMeta": "{{n}} sessions",
93
+ "sessions.cronLoading": "Loading scheduled tasks...",
91
94
  "sessions.badge.cron": "Auto",
92
95
  "sessions.badge.channel": "Channel",
93
96
  "sessions.badge.coding": "Coding",
@@ -635,6 +638,9 @@ const I18n = (() => {
635
638
  "sib.reasoning.high": "高",
636
639
  "sessions.thinking": "思考中…",
637
640
  "sessions.default_name": "对话 {{n}}",
641
+ "sessions.cronGroup": "定时任务",
642
+ "sessions.cronGroupMeta": "{{n}} 个会话",
643
+ "sessions.cronLoading": "正在加载定时任务...",
638
644
  "sessions.badge.cron": "定时",
639
645
  "sessions.badge.channel": "频道",
640
646
  "sessions.badge.coding": "Coding",
@@ -119,6 +119,17 @@
119
119
  </div>
120
120
  </div>
121
121
 
122
+ <!-- Cron view header (hidden by default, shown when viewing cron sessions) -->
123
+ <div id="cron-view-header" class="sidebar-divider" style="display:none">
124
+ <button id="btn-cron-back" class="btn-icon-sm" title="Back" aria-label="Back to session list">
125
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
126
+ <path d="M10 3L5 8l5 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
127
+ </svg>
128
+ </button>
129
+ <span data-i18n="sessions.cronGroup">Scheduled Tasks</span>
130
+ <span></span><!-- spacer -->
131
+ </div>
132
+
122
133
  <!-- Unified session list (all sources + profiles, newest first) -->
123
134
  <div id="session-list"></div>
124
135
  <!-- Load more button rendered dynamically by Sessions.renderList() -->
@@ -25,6 +25,8 @@ const Sessions = (() => {
25
25
  // Search state
26
26
  const _filter = { q: "", date: "", type: "" }; // committed filter (applied to server)
27
27
  let _searchOpen = false; // is the search panel visible?
28
+ let _cronView = false; // are we in the cron sub-view?
29
+ let _cronCount = 0; // total cron sessions from server
28
30
  let _pendingRunTaskId = null; // session_id waiting to send "run_task" after subscribe
29
31
  let _pendingMessage = null; // { session_id, content } — slash command to send after subscribe
30
32
  // Buffer for tool_stdout lines that arrive before history has finished rendering.
@@ -1637,6 +1639,49 @@ const Sessions = (() => {
1637
1639
  container.appendChild(el);
1638
1640
  }
1639
1641
 
1642
+ // ── Cron group entry (renders the folded "Scheduled Tasks" entry) ─────
1643
+ function _renderCronGroupItem(container, count) {
1644
+ const el = document.createElement("div");
1645
+ el.className = "session-item cron-group-item";
1646
+ el.innerHTML = `
1647
+ <div class="session-body">
1648
+ <div class="session-name">
1649
+ <span class="session-dot dot-idle" style="display:inline-block;opacity:0.6"></span>
1650
+ <span class="session-name__text">📋 ${I18n.t("sessions.cronGroup")} (${count})</span>
1651
+ </div>
1652
+ <div class="session-meta">${I18n.t("sessions.cronGroupMeta", { n: count })}</div>
1653
+ </div>
1654
+ `;
1655
+ el.onclick = () => {
1656
+ _cronView = true;
1657
+ Sessions.renderList();
1658
+ };
1659
+ container.appendChild(el);
1660
+ }
1661
+
1662
+ // ── Chat-section header visibility ────────────────────────────────────
1663
+ function _updateChatHeader(isCronView) {
1664
+ const chatSection = document.getElementById("chat-section");
1665
+ if (!chatSection) return;
1666
+
1667
+ const normalHeader = chatSection.querySelector(":scope > .sidebar-divider:first-of-type");
1668
+ const cronHeader = document.getElementById("cron-view-header");
1669
+ const searchBar = document.getElementById("session-search-bar");
1670
+ const newSessionBtn = document.getElementById("btn-session-search-toggle");
1671
+
1672
+ if (isCronView) {
1673
+ if (normalHeader) normalHeader.style.display = "none";
1674
+ if (cronHeader) cronHeader.style.display = "";
1675
+ if (searchBar) searchBar.hidden = true;
1676
+ if (newSessionBtn) newSessionBtn.style.display = "none";
1677
+ } else {
1678
+ if (normalHeader) normalHeader.style.display = "";
1679
+ if (cronHeader) cronHeader.style.display = "none";
1680
+ if (searchBar) searchBar.hidden = !_searchOpen;
1681
+ // newSessionBtn display managed by renderList's magnifier logic
1682
+ }
1683
+ }
1684
+
1640
1685
  // ── Public API ─────────────────────────────────────────────────────────
1641
1686
  return {
1642
1687
  get all() { return _sessions; },
@@ -1658,6 +1703,14 @@ const Sessions = (() => {
1658
1703
  _initMessageHistory();
1659
1704
  // Re-render session list (badges/labels) when the user switches language
1660
1705
  document.addEventListener("langchange", () => Sessions.renderList());
1706
+
1707
+ // Cron view back button
1708
+ document.getElementById("btn-cron-back")
1709
+ .addEventListener("click", () => {
1710
+ _cronView = false;
1711
+ Sessions.renderList();
1712
+ });
1713
+
1661
1714
  // Browsers block file:// navigation from http:// pages. Intercept clicks on
1662
1715
  // file:// links and delegate to the backend API.
1663
1716
  // Local deployments (localhost / 127.0.0.1 / ::1): open the file with the
@@ -1702,16 +1755,18 @@ const Sessions = (() => {
1702
1755
  // ── List management ───────────────────────────────────────────────────
1703
1756
 
1704
1757
  /** Populate list from initial session_list WS event (connect only). */
1705
- setAll(list, hasMore = false) {
1758
+ setAll(list, hasMore = false, cronCount = 0) {
1706
1759
  _sessions.length = 0;
1707
1760
  _sessions.push(...list);
1708
- _hasMore = !!hasMore;
1761
+ _hasMore = !!hasMore;
1762
+ _cronCount = cronCount;
1709
1763
  },
1710
1764
 
1711
1765
  /** Insert a newly created session into the local list. */
1712
1766
  add(session) {
1713
1767
  if (!_sessions.find(s => s.id === session.id)) {
1714
1768
  _sessions.push(session);
1769
+ if (session.source === "cron") _cronCount++;
1715
1770
  }
1716
1771
  },
1717
1772
 
@@ -1730,7 +1785,10 @@ const Sessions = (() => {
1730
1785
  /** Remove a session from the list (from session_deleted event). */
1731
1786
  remove(id) {
1732
1787
  const idx = _sessions.findIndex(s => s.id === id);
1733
- if (idx !== -1) _sessions.splice(idx, 1);
1788
+ if (idx !== -1) {
1789
+ if (_sessions[idx].source === "cron") _cronCount = Math.max(0, _cronCount - 1);
1790
+ _sessions.splice(idx, 1);
1791
+ }
1734
1792
  // Clean up per-session progress state (timer + DOM + logical state)
1735
1793
  Sessions._deleteProgressState(id);
1736
1794
  },
@@ -1772,7 +1830,8 @@ const Sessions = (() => {
1772
1830
  (data.sessions || []).forEach(s => {
1773
1831
  if (!_sessions.find(x => x.id === s.id)) _sessions.push(s);
1774
1832
  });
1775
- _hasMore = !!data.has_more;
1833
+ _hasMore = !!data.has_more;
1834
+ _cronCount = data.cron_count || 0;
1776
1835
  } catch (e) {
1777
1836
  console.error("loadMore error:", e);
1778
1837
  } finally {
@@ -1809,7 +1868,8 @@ const Sessions = (() => {
1809
1868
  if (!res.ok) return;
1810
1869
  const data = await res.json();
1811
1870
  _sessions.push(...(data.sessions || []));
1812
- _hasMore = !!data.has_more;
1871
+ _hasMore = !!data.has_more;
1872
+ _cronCount = data.cron_count || 0;
1813
1873
  } catch (e) {
1814
1874
  console.error("commitSearch error:", e);
1815
1875
  } finally {
@@ -1994,14 +2054,53 @@ const Sessions = (() => {
1994
2054
  const qEl = document.getElementById("session-search-q");
1995
2055
  if (qClearBtn) qClearBtn.hidden = !(qEl && qEl.value);
1996
2056
 
2057
+ // ── Split cron vs non-cron for folding ───────────────────────────
2058
+ const hasActiveFilter = !!(_filter.q || _filter.type || _filter.date);
2059
+ const isCronView = _cronView && !hasActiveFilter;
2060
+ const cronSessions = visible.filter(s => s.source === "cron");
2061
+ const nonCronSessions = visible.filter(s => s.source !== "cron");
2062
+
2063
+ // Update chat-section header based on view mode
2064
+ _updateChatHeader(isCronView);
2065
+
1997
2066
  const list = $("session-list");
1998
2067
  list.innerHTML = "";
1999
- if (visible.length === 0) {
2000
- list.innerHTML = `<div class="session-empty">${I18n.t("sessions.empty")}</div>`;
2068
+
2069
+ if (hasActiveFilter) {
2070
+ // Filter active: show all matching results flat, no group entry
2071
+ visible.forEach(s => _renderSessionItem(list, s));
2072
+ } else if (isCronView) {
2073
+ // Cron sub-view: show only cron sessions.
2074
+ // If none are loaded yet, auto-load more pages until we find them.
2075
+ if (cronSessions.length === 0) {
2076
+ if (_hasMore && !_loadingMore) {
2077
+ list.innerHTML = `<div class="session-empty">${I18n.t("sessions.cronLoading")}</div>`;
2078
+ Sessions.loadMore(); // async — will call renderList() again when done
2079
+ return; // skip empty-state / load-more button for now
2080
+ }
2081
+ if (_loadingMore) {
2082
+ // A loadMore() call is already in flight (its own renderList call
2083
+ // reached us). Keep the loading indicator so the user never sees
2084
+ // the "no sessions" empty state during the gap.
2085
+ list.innerHTML = `<div class="session-empty">${I18n.t("sessions.cronLoading")}</div>`;
2086
+ return;
2087
+ }
2088
+ }
2089
+ cronSessions.forEach(s => _renderSessionItem(list, s));
2090
+ } else if (_cronCount > 0) {
2091
+ // Normal list view: group entry (uses total count, not just loaded) + non-cron sessions
2092
+ _renderCronGroupItem(list, _cronCount);
2093
+ nonCronSessions.forEach(s => _renderSessionItem(list, s));
2001
2094
  } else {
2095
+ // Normal list view, no cron sessions
2002
2096
  visible.forEach(s => _renderSessionItem(list, s));
2003
2097
  }
2004
2098
 
2099
+ // Empty state fallback
2100
+ if (list.children.length === 0) {
2101
+ list.innerHTML = `<div class="session-empty">${I18n.t("sessions.empty")}</div>`;
2102
+ }
2103
+
2005
2104
  if (_hasMore) list.appendChild(_makeLoadMoreBtn());
2006
2105
 
2007
2106
  // Scroll active session into view so the sidebar always shows the current session.
@@ -47,7 +47,7 @@ WS.onEvent(ev => {
47
47
 
48
48
  // ── Session list ───────────────────────────────────────────────────
49
49
  case "session_list": {
50
- Sessions.setAll(ev.sessions || [], !!ev.has_more);
50
+ Sessions.setAll(ev.sessions || [], !!ev.has_more, ev.cron_count || 0);
51
51
  Sessions.renderList();
52
52
 
53
53
  // Restore URL hash once on initial connect; ignore subsequent session_list events.
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.1.4
4
+ version: 1.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-21 00:00:00.000000000 Z
11
+ date: 2026-05-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday