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 +4 -4
- data/CHANGELOG.md +13 -0
- data/lib/clacky/cli.rb +6 -58
- data/lib/clacky/server/http_server.rb +3 -21
- data/lib/clacky/server/session_registry.rb +6 -0
- data/lib/clacky/skill_loader.rb +5 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/brand.js +46 -31
- data/lib/clacky/web/i18n.js +6 -0
- data/lib/clacky/web/index.html +11 -0
- data/lib/clacky/web/sessions.js +106 -7
- data/lib/clacky/web/ws-dispatcher.js +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d07cc1b558deca4a0bcbbd49133b1e75541437e576811fff6848ea205a1df9e2
|
|
4
|
+
data.tar.gz: 547e9b2215f53f41902fdfd5f03c99e0f3fd1d532967279c9eb83f62a57d0a0a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
342
|
-
#
|
|
343
|
-
#
|
|
344
|
-
#
|
|
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
|
|
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
|
data/lib/clacky/skill_loader.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky/web/brand.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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(
|
|
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
|
-
|
|
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";
|
data/lib/clacky/web/i18n.js
CHANGED
|
@@ -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",
|
data/lib/clacky/web/index.html
CHANGED
|
@@ -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() -->
|
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
|
|
2000
|
-
|
|
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
|
+
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-
|
|
11
|
+
date: 2026-05-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|