openclacky 0.8.9 → 0.9.0

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: 9fbd7535e2cc98aadf0d9bbc3ff8dcc3db04669ba5c972ec00b41c338f1c3ae5
4
- data.tar.gz: 345be84b2b010db47ccdd0454b0214463117519fe5d029440290c0187006ff4a
3
+ metadata.gz: 732978cd6072f33b558e6260af317ffa188529f922913193d9b7505745f0bb2b
4
+ data.tar.gz: 3a03afdbb194bbf3e52cd89d173a05186e01ce4cb8564410e294e0e5bb7db501
5
5
  SHA512:
6
- metadata.gz: 317f05f87355eb3c58da51dde828b6fafd3abbc7cc8bfa874c19445b5a783f7687ee84c7e8ddcf59573b7a554687ee8b2c9e5a8ed5d14993b6167fe5a656a881
7
- data.tar.gz: ce669491b51cbff8f3fbdea2262d51d206377617d69ad981ac5025b824b86638d78d248ea6dcd89f4b9ecca92d042efa91b783dff220bec5c4ac348c8535b6d6
6
+ metadata.gz: 0ad076fe8aa80fe40b8f9908cdf6bab87c08110c8940c76aa3d8fe5944886f565735b7dd06008e9f54fc0a13a3b1d5966800d3bb02bf1d7268cf99faab181dd4
7
+ data.tar.gz: e29e2688839d08a271b105830df847517cfaab57ecae36d50787687180b1ba9a41e15a68f6e3afc21a800dd78b6eea6c7556c97c503aabdc46c2763bdf1c0bb8
data/CHANGELOG.md CHANGED
@@ -7,9 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [0.8.9] - 2026-03-13
10
+ ## [0.9.0] - 2026-03-14
11
11
 
12
12
  ### Added
13
+ - **Version check and one-click upgrade in WebUI**: a version badge in the sidebar shows when a newer gem is available; clicking it opens an upgrade popover with a live install log and a restart button — no terminal needed
14
+ - **Upgrade badge state machine**: the badge cycles through four visual states — amber pulsing dot (update available), spinning ring (installing), orange bouncing dot (restart needed), green check (restarted successfully)
13
15
  - **Markdown rendering in WebUI chat**: assistant responses are now rendered as rich markdown — headings, bold, code blocks, lists, and inline code are all formatted properly instead of displayed as raw text
14
16
  - **Session naming with auto-name and inline rename**: sessions are automatically named after the first exchange; users can double-click any session in the sidebar to rename it inline
15
17
  - **Session info bar with live status animation**: a slim bar below the chat header shows the session name, working directory, and a pulsing animation while the agent is thinking or executing tools
@@ -18,10 +20,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
18
20
  - **Idle compression in WebUI**: the agent now compresses long conversation history automatically when the session has been idle, keeping context efficient without manual intervention
19
21
 
20
22
  ### Improved
23
+ - **Glob tool recursive search**: bare pattern names like `controller` are now automatically expanded to `**/*controller*` so searches always return results across all subdirectories
21
24
  - **Onboard flow**: soul setup is now non-blocking; the confirmation page is skipped for a faster first-run experience; onboard now asks the user to name the AI first, then collects the user profile
22
25
  - **Token usage display ordering**: the token usage line in WebUI now always appears below the assistant message bubble, not above it
26
+ - **i18n coverage**: settings panel dynamically-rendered fields are now translated correctly at render time
23
27
 
24
28
  ### Fixed
29
+ - **Upgrade popover stays open during install and reconnect**: the popover is now locked while a gem install or server restart is in progress, preventing accidental dismissal that would leave the badge stuck in a spinning state
30
+ - **Session auto-name respects default placeholders**: session names are now assigned based on message history only, not the agent's internal name field, so placeholder names like "Session 1" no longer block the auto-naming logic
25
31
  - **Token usage line disappears after page refresh**: token usage data is now persisted in session history and correctly re-rendered when the page is reloaded
26
32
  - **Shell tool hangs on background commands**: commands containing `&` (background operator) no longer cause the shell tool to block indefinitely
27
33
  - **White flash on page load**: the page is now hidden until boot completes, preventing a flash of unstyled content or the wrong view on startup
@@ -131,10 +131,20 @@ module Clacky
131
131
  @agent_config = agent_config
132
132
  @client_factory = client_factory # callable: -> { Clacky::Client.new(...) }
133
133
  @brand_test = brand_test # when true, skip remote API calls for license activation
134
+ # Capture the absolute path of the entry script and original ARGV at startup,
135
+ # so api_restart can re-exec the correct binary even if cwd changes later.
136
+ @restart_script = File.expand_path($0)
137
+ @restart_argv = ARGV.dup
134
138
  @registry = SessionRegistry.new
135
139
  @session_manager = Clacky::SessionManager.new
136
140
  @ws_clients = {} # session_id => [WebSocketConnection, ...]
137
141
  @ws_mutex = Mutex.new
142
+ # Version cache: { latest: "x.y.z", checked_at: Time }
143
+ @version_cache = nil
144
+ @version_mutex = Mutex.new
145
+ # Version cache: { latest: "x.y.z", checked_at: Time }
146
+ @version_cache = nil
147
+ @version_mutex = Mutex.new
138
148
  @scheduler = Scheduler.new(
139
149
  session_registry: @registry,
140
150
  session_builder: method(:build_session)
@@ -264,6 +274,9 @@ module Clacky
264
274
  when ["GET", "/api/brand"] then api_brand_info(res)
265
275
  when ["GET", "/api/channels"] then api_list_channels(res)
266
276
  when ["POST", "/api/upload"] then api_upload_file(req, res)
277
+ when ["GET", "/api/version"] then api_get_version(res)
278
+ when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
279
+ when ["POST", "/api/restart"] then api_restart(req, res)
267
280
  else
268
281
  if method == "POST" && path.match?(%r{^/api/channels/[^/]+/test$})
269
282
  platform = path.sub("/api/channels/", "").sub("/test", "")
@@ -591,6 +604,111 @@ module Clacky
591
604
  json_response(res, 200, brand.to_h)
592
605
  end
593
606
 
607
+ # ── Version API ───────────────────────────────────────────────────────────
608
+
609
+ # GET /api/version
610
+ # Returns current version and latest version from RubyGems (cached for 1 hour).
611
+ def api_get_version(res)
612
+ current = Clacky::VERSION
613
+ latest = fetch_latest_version_cached
614
+ json_response(res, 200, {
615
+ current: current,
616
+ latest: latest,
617
+ needs_update: latest ? version_older?(current, latest) : false
618
+ })
619
+ end
620
+
621
+ # POST /api/version/upgrade
622
+ # Runs `gem update openclacky --no-document` via Clacky::Tools::Shell (login shell)
623
+ # in a background thread, streaming output via WebSocket broadcast.
624
+ # On success, re-execs the process so the new gem version is loaded.
625
+ def api_upgrade_version(req, res)
626
+ json_response(res, 202, { ok: true, message: "Upgrade started" })
627
+
628
+ Thread.new do
629
+ begin
630
+ broadcast_all(type: "upgrade_log", line: "Starting upgrade: gem update openclacky --no-document\n")
631
+
632
+ shell = Clacky::Tools::Shell.new
633
+ result = shell.execute(command: "gem update openclacky --no-document",
634
+ soft_timeout: 300, hard_timeout: 600)
635
+ output = [result[:stdout], result[:stderr]].join
636
+ success = result[:exit_code] == 0
637
+
638
+ broadcast_all(type: "upgrade_log", line: output)
639
+
640
+ if success
641
+ broadcast_all(type: "upgrade_log", line: "\n✓ Upgrade successful! Please restart the server to apply the new version.\n")
642
+ broadcast_all(type: "upgrade_complete", success: true)
643
+ else
644
+ broadcast_all(type: "upgrade_log", line: "\n✗ Upgrade failed. Please try manually: gem update openclacky\n")
645
+ broadcast_all(type: "upgrade_complete", success: false)
646
+ end
647
+ rescue StandardError => e
648
+ broadcast_all(type: "upgrade_log", line: "\n✗ Error during upgrade: #{e.message}\n")
649
+ broadcast_all(type: "upgrade_complete", success: false)
650
+ end
651
+ end
652
+ end
653
+
654
+ # POST /api/restart
655
+ # Re-execs the current process so the newly installed gem version is loaded.
656
+ # Uses the absolute script path captured at startup to avoid relative-path issues.
657
+ # Responds 200 first, then waits briefly for WEBrick to flush the response before exec.
658
+ def api_restart(req, res)
659
+ json_response(res, 200, { ok: true, message: "Restarting…" })
660
+
661
+ script = @restart_script
662
+ argv = @restart_argv
663
+ Thread.new do
664
+ sleep 0.5 # Let WEBrick flush the HTTP response
665
+ Clacky::Logger.info("[Restart] exec: #{RbConfig.ruby} #{script} #{argv.join(' ')}")
666
+ exec(RbConfig.ruby, script, *argv)
667
+ end
668
+ end
669
+
670
+ # Fetch the latest gem version using `gem list -r`, with a 1-hour in-memory cache.
671
+ # Uses Clacky::Tools::Shell (login shell) so rbenv/mise shims and gem mirrors work correctly.
672
+ private def fetch_latest_version_cached
673
+ @version_mutex.synchronize do
674
+ now = Time.now
675
+ if @version_cache && (now - @version_cache[:checked_at]) < 3600
676
+ return @version_cache[:latest]
677
+ end
678
+ end
679
+
680
+ # Fetch outside the mutex to avoid blocking other requests
681
+ latest = fetch_latest_version_from_gem
682
+
683
+ @version_mutex.synchronize do
684
+ @version_cache = { latest: latest, checked_at: Time.now }
685
+ end
686
+
687
+ latest
688
+ end
689
+
690
+ # Query the latest openclacky version via `gem list -r openclacky`.
691
+ # Runs through login shell so gem source mirrors configured via rbenv/mise work correctly.
692
+ # Output format: "openclacky (0.9.0)"
693
+ private def fetch_latest_version_from_gem
694
+ shell = Clacky::Tools::Shell.new
695
+ result = shell.execute(command: "gem list -r openclacky", soft_timeout: 15, hard_timeout: 30)
696
+ return nil unless result[:exit_code] == 0
697
+
698
+ out = result[:stdout].to_s
699
+ match = out.match(/^openclacky\s+\(([^)]+)\)/)
700
+ match ? match[1].strip : nil
701
+ rescue StandardError
702
+ nil
703
+ end
704
+
705
+ # Returns true if version string `a` is strictly older than `b`.
706
+ private def version_older?(a, b)
707
+ Gem::Version.new(a) < Gem::Version.new(b)
708
+ rescue ArgumentError
709
+ false
710
+ end
711
+
594
712
  # ── Channel API ───────────────────────────────────────────────────────────
595
713
 
596
714
  # GET /api/channels
@@ -1354,8 +1472,10 @@ module Clacky
1354
1472
  end
1355
1473
  content = [content, *file_refs].join("\n") unless file_refs.empty?
1356
1474
 
1357
- # Auto-name the session from the first user message (before agent starts running)
1358
- if agent.name.empty? && agent.messages.empty?
1475
+ # Auto-name the session from the first user message (before agent starts running).
1476
+ # Check messages.empty? only agent.name may already hold a default placeholder
1477
+ # like "Session 1" assigned at creation time, so it's not a reliable signal.
1478
+ if agent.messages.empty?
1359
1479
  auto_name = content.gsub(/\s+/, " ").strip[0, 30]
1360
1480
  auto_name += "…" if content.strip.length > 30
1361
1481
  agent.rename(auto_name)
@@ -1635,15 +1755,23 @@ module Clacky
1635
1755
 
1636
1756
  pid = File.read(pid_file).strip.to_i
1637
1757
  return if pid <= 0
1758
+ # After exec-restart, the new process inherits the same PID as the old one.
1759
+ # Skip sending TERM to ourselves — we are already the new server.
1760
+ if pid == Process.pid
1761
+ Clacky::Logger.info("[Server] exec-restart detected (PID=#{pid}), skipping self-kill.")
1762
+ return
1763
+ end
1638
1764
 
1639
1765
  begin
1640
1766
  Process.kill("TERM", pid)
1767
+ Clacky::Logger.info("[Server] Stopped existing server (PID=#{pid}) on port #{port}.")
1641
1768
  puts "Stopped existing server (PID: #{pid}) on port #{port}."
1642
1769
  # Give it a moment to release the port
1643
1770
  sleep 0.5
1644
1771
  rescue Errno::ESRCH
1645
- # Process already gone — nothing to do
1772
+ Clacky::Logger.info("[Server] Existing server PID=#{pid} already gone.")
1646
1773
  rescue Errno::EPERM
1774
+ Clacky::Logger.warn("[Server] Could not stop existing server (PID=#{pid}) — permission denied.")
1647
1775
  puts "Could not stop existing server (PID: #{pid}) — permission denied."
1648
1776
  ensure
1649
1777
  File.delete(pid_file) if File.exist?(pid_file)
@@ -63,11 +63,22 @@ module Clacky
63
63
  ignored: 0
64
64
  }
65
65
 
66
+ # Auto-expand bare patterns (no slash, no **) to recursive search.
67
+ # e.g. "*install*" -> "**/*install*", "*.rb" -> "**/*.rb"
68
+ # This avoids surprising empty results when files are in subdirectories.
69
+ effective_pattern = if !File.absolute_path?(pattern) &&
70
+ !pattern.include?("/") &&
71
+ !pattern.start_with?("**")
72
+ "**/#{pattern}"
73
+ else
74
+ pattern
75
+ end
76
+
66
77
  # Build full pattern - handle absolute paths correctly
67
- full_pattern = if File.absolute_path?(pattern)
68
- pattern
78
+ full_pattern = if File.absolute_path?(effective_pattern)
79
+ effective_pattern
69
80
  else
70
- File.join(base_path, pattern)
81
+ File.join(base_path, effective_pattern)
71
82
  end
72
83
  # Always-ignored directory names that should never appear in results
73
84
  always_ignored_dirs = Clacky::Utils::FileIgnoreHelper::ALWAYS_IGNORED_DIRS
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.8.9"
4
+ VERSION = "0.9.0"
5
5
  end
@@ -33,6 +33,7 @@
33
33
 
34
34
  --color-button-primary: #60a5fa;
35
35
  --color-button-primary-hover: #3d8bd8;
36
+ --color-button-primary-text: #ffffff;
36
37
 
37
38
  --color-info: #38bdf8;
38
39
  --color-secondary: #34d399;
@@ -77,6 +78,7 @@
77
78
 
78
79
  --color-button-primary: #3b82f6;
79
80
  --color-button-primary-hover: #2563eb;
81
+ --color-button-primary-text: #ffffff;
80
82
 
81
83
  --color-info: #38bdf8;
82
84
  --color-secondary: #10b981;
@@ -3060,3 +3062,314 @@ body {
3060
3062
  border: 1px dashed var(--color-border-primary);
3061
3063
  border-radius: 8px;
3062
3064
  }
3065
+
3066
+ /* ── Version badge (inline in Settings row, right-aligned) ──────────────── */
3067
+
3068
+ /* Settings button stretches to fill width; version badge pushes to the right */
3069
+ #btn-settings {
3070
+ position: relative;
3071
+ }
3072
+
3073
+ .version-badge {
3074
+ display: inline-flex;
3075
+ align-items: center;
3076
+ gap: 5px;
3077
+ margin-left: auto; /* push to right inside flex btn-settings */
3078
+ padding: 2px 6px;
3079
+ border-radius: 4px;
3080
+ cursor: default;
3081
+ transition: background .15s;
3082
+ flex-shrink: 0;
3083
+ }
3084
+ /* When actionable, clicking the badge opens the popover.
3085
+ We intercept click on the badge span, stop propagation to avoid
3086
+ opening the settings panel at the same time. */
3087
+ .version-badge.has-update,
3088
+ .version-badge.upgrade-done,
3089
+ .version-badge.is-upgrading { cursor: pointer; }
3090
+
3091
+ .version-text {
3092
+ font-size: 10.5px;
3093
+ color: var(--color-text-muted);
3094
+ font-family: monospace;
3095
+ letter-spacing: 0.03em;
3096
+ transition: color .15s;
3097
+ line-height: 1;
3098
+ }
3099
+ /* Highlight version text when badge is actionable and btn is hovered */
3100
+ #btn-settings:hover .version-text { color: var(--color-text-tertiary); }
3101
+ .version-badge.has-update .version-text,
3102
+ .version-badge.upgrade-done .version-text { color: var(--color-accent-primary); }
3103
+
3104
+ /* Amber pulsing dot — update available */
3105
+ .version-update-dot {
3106
+ width: 7px;
3107
+ height: 7px;
3108
+ border-radius: 50%;
3109
+ background: var(--color-warning);
3110
+ flex-shrink: 0;
3111
+ box-shadow: 0 0 5px var(--color-warning);
3112
+ animation: vbadge-pulse 2s ease-in-out infinite;
3113
+ }
3114
+ @keyframes vbadge-pulse {
3115
+ 0%, 100% { opacity: 1; box-shadow: 0 0 5px var(--color-warning); }
3116
+ 50% { opacity: 0.4; box-shadow: 0 0 2px var(--color-warning); }
3117
+ }
3118
+
3119
+ /* Tiny spinning ring — upgrade in progress */
3120
+ .version-spinner {
3121
+ width: 9px;
3122
+ height: 9px;
3123
+ border: 1.5px solid var(--color-border-primary);
3124
+ border-top-color: var(--color-accent-primary);
3125
+ border-radius: 50%;
3126
+ flex-shrink: 0;
3127
+ animation: vbadge-spin .7s linear infinite;
3128
+ }
3129
+ @keyframes vbadge-spin { to { transform: rotate(360deg); } }
3130
+
3131
+ /* Orange bouncing dot — needs restart (upgrade done, waiting for restart) */
3132
+ .version-restart-dot {
3133
+ width: 7px;
3134
+ height: 7px;
3135
+ border-radius: 50%;
3136
+ background: #f97316; /* orange-500 */
3137
+ flex-shrink: 0;
3138
+ animation: vbadge-bounce 1s ease-in-out infinite;
3139
+ }
3140
+ @keyframes vbadge-bounce {
3141
+ 0%, 100% { transform: translateY(0); opacity: 1; }
3142
+ 50% { transform: translateY(-3px); opacity: 0.7; }
3143
+ }
3144
+
3145
+ /* Green check — restarted successfully */
3146
+ .version-done-check {
3147
+ font-size: 10px;
3148
+ color: #22c55e;
3149
+ flex-shrink: 0;
3150
+ line-height: 1;
3151
+ animation: vbadge-popin .25s cubic-bezier(.34,1.56,.64,1) both;
3152
+ }
3153
+ @keyframes vbadge-popin {
3154
+ from { transform: scale(0); opacity: 0; }
3155
+ to { transform: scale(1); opacity: 1; }
3156
+ }
3157
+
3158
+ /* ── Upgrade popover (fixed, floats above badge) ─────────────────────────── */
3159
+ .vup {
3160
+ position: fixed;
3161
+ z-index: 9999;
3162
+ width: 240px;
3163
+ background: var(--color-bg-secondary);
3164
+ border: 1px solid var(--color-border-primary);
3165
+ border-radius: 10px;
3166
+ box-shadow: 0 8px 32px rgba(0,0,0,.22), 0 2px 8px rgba(0,0,0,.12);
3167
+ padding: 14px 16px 12px;
3168
+ display: none;
3169
+ /* Slide-up + fade entrance */
3170
+ opacity: 0;
3171
+ transform: translateY(6px);
3172
+ transition: opacity .18s ease, transform .18s ease;
3173
+ }
3174
+ .vup--visible {
3175
+ opacity: 1;
3176
+ transform: translateY(0);
3177
+ }
3178
+ /* Tiny arrow pointing down toward badge */
3179
+ .vup::after {
3180
+ content: "";
3181
+ position: absolute;
3182
+ bottom: -6px;
3183
+ left: 20px;
3184
+ width: 10px;
3185
+ height: 10px;
3186
+ background: var(--color-bg-secondary);
3187
+ border-right: 1px solid var(--color-border-primary);
3188
+ border-bottom: 1px solid var(--color-border-primary);
3189
+ transform: rotate(45deg);
3190
+ }
3191
+
3192
+ /* Confirm state */
3193
+ .vup-desc {
3194
+ font-size: 12px;
3195
+ color: var(--color-text-secondary);
3196
+ line-height: 1.55;
3197
+ margin: 0 0 8px;
3198
+ }
3199
+ .vup-versions {
3200
+ font-size: 12px;
3201
+ font-family: monospace;
3202
+ color: var(--color-text-primary);
3203
+ margin: 0 0 12px;
3204
+ font-weight: 600;
3205
+ }
3206
+ .vup-arrow {
3207
+ color: var(--color-accent-primary);
3208
+ margin: 0 4px;
3209
+ }
3210
+ .vup-actions {
3211
+ display: flex;
3212
+ gap: 8px;
3213
+ }
3214
+ .vup-btn-primary {
3215
+ flex: 1;
3216
+ padding: 7px 0;
3217
+ background: var(--color-button-primary);
3218
+ color: var(--color-button-primary-text);
3219
+ border: none;
3220
+ border-radius: 6px;
3221
+ font-size: 12px;
3222
+ font-weight: 600;
3223
+ cursor: pointer;
3224
+ transition: background .15s, transform .1s;
3225
+ }
3226
+ .vup-btn-primary:hover { background: var(--color-button-primary-hover); }
3227
+ .vup-btn-primary:active { transform: scale(.97); }
3228
+ .vup-btn-cancel {
3229
+ padding: 7px 12px;
3230
+ background: transparent;
3231
+ color: var(--color-text-muted);
3232
+ border: 1px solid var(--color-border-primary);
3233
+ border-radius: 6px;
3234
+ font-size: 12px;
3235
+ cursor: pointer;
3236
+ transition: background .15s, color .15s;
3237
+ }
3238
+ .vup-btn-cancel:hover { background: var(--color-bg-hover); color: var(--color-text-secondary); }
3239
+
3240
+ /* Progress state */
3241
+ .vup-progress-header {
3242
+ display: flex;
3243
+ align-items: center;
3244
+ gap: 8px;
3245
+ margin-bottom: 10px;
3246
+ }
3247
+ .vup-installing-label {
3248
+ font-size: 12px;
3249
+ color: var(--color-text-secondary);
3250
+ }
3251
+ /* Animated 3-dot ellipsis */
3252
+ .vup-installing-dot {
3253
+ display: inline-flex;
3254
+ gap: 3px;
3255
+ }
3256
+ .vup-installing-dot::before,
3257
+ .vup-installing-dot::after,
3258
+ .vup-installing-dot b {
3259
+ content: "";
3260
+ display: inline-block;
3261
+ width: 5px;
3262
+ height: 5px;
3263
+ border-radius: 50%;
3264
+ background: var(--color-accent-primary);
3265
+ animation: vup-bounce 1.2s ease-in-out infinite;
3266
+ }
3267
+ .vup-installing-dot::after { animation-delay: .2s; }
3268
+ /* We use a pseudo trick — actual 3 dots via box-shadow instead */
3269
+ .vup-installing-dot {
3270
+ width: 5px;
3271
+ height: 5px;
3272
+ border-radius: 50%;
3273
+ background: var(--color-accent-primary);
3274
+ animation: vup-bounce 1.2s ease-in-out infinite;
3275
+ box-shadow: 10px 0 0 var(--color-accent-primary), 20px 0 0 var(--color-accent-primary);
3276
+ margin-right: 24px; /* make room for box-shadow dots */
3277
+ flex-shrink: 0;
3278
+ }
3279
+ .vup-installing-dot::before,
3280
+ .vup-installing-dot::after { display: none; }
3281
+
3282
+ /* Stagger the shadow dots via custom animation composition */
3283
+ .vup-installing-dot {
3284
+ animation: vup-bounce1 1.2s ease-in-out infinite;
3285
+ }
3286
+ @keyframes vup-bounce1 {
3287
+ 0%, 80%, 100% { box-shadow: 10px 0 0 var(--color-accent-primary), 20px 0 0 var(--color-accent-primary); opacity: 1; }
3288
+ 40% { box-shadow: 10px 0 0 var(--color-accent-primary), 20px 0 0 var(--color-accent-primary); opacity: .4; }
3289
+ }
3290
+ @keyframes vup-bounce { 0%, 80%, 100% { opacity: 1; } 40% { opacity: .3; } }
3291
+
3292
+ .vup-log {
3293
+ background: var(--color-bg-primary);
3294
+ border: 1px solid var(--color-border-primary);
3295
+ border-radius: 6px;
3296
+ padding: 8px 10px;
3297
+ font-size: 10.5px;
3298
+ font-family: monospace;
3299
+ line-height: 1.5;
3300
+ color: var(--color-text-muted);
3301
+ max-height: 140px;
3302
+ min-height: 60px;
3303
+ overflow-y: auto;
3304
+ white-space: pre-wrap;
3305
+ word-break: break-all;
3306
+ margin: 0;
3307
+ }
3308
+
3309
+ /* Done state */
3310
+ .vup-done-header {
3311
+ display: flex;
3312
+ align-items: center;
3313
+ gap: 8px;
3314
+ margin-bottom: 12px;
3315
+ font-size: 12px;
3316
+ color: var(--color-text-secondary);
3317
+ }
3318
+ .vup-done-icon {
3319
+ width: 20px;
3320
+ height: 20px;
3321
+ border-radius: 50%;
3322
+ background: #22c55e;
3323
+ color: #fff;
3324
+ display: flex;
3325
+ align-items: center;
3326
+ justify-content: center;
3327
+ font-size: 11px;
3328
+ font-weight: 700;
3329
+ flex-shrink: 0;
3330
+ animation: vbadge-popin .3s cubic-bezier(.34,1.56,.64,1) both;
3331
+ }
3332
+ .vup-btn-restart {
3333
+ width: 100%;
3334
+ padding: 9px 0;
3335
+ background: var(--color-button-primary);
3336
+ color: var(--color-button-primary-text);
3337
+ border: none;
3338
+ border-radius: 6px;
3339
+ font-size: 13px;
3340
+ font-weight: 600;
3341
+ cursor: pointer;
3342
+ transition: background .15s, transform .1s;
3343
+ letter-spacing: .02em;
3344
+ }
3345
+ .vup-btn-restart:hover { background: var(--color-button-primary-hover); }
3346
+ .vup-btn-restart:active { transform: scale(.97); }
3347
+
3348
+ /* Reconnect state */
3349
+ .vup-reconnect {
3350
+ text-align: center;
3351
+ padding: 8px 0 4px;
3352
+ }
3353
+ .vup-reconnect-spinner {
3354
+ width: 28px;
3355
+ height: 28px;
3356
+ border: 2.5px solid var(--color-border-primary);
3357
+ border-top-color: var(--color-accent-primary);
3358
+ border-radius: 50%;
3359
+ animation: vbadge-spin .7s linear infinite;
3360
+ margin: 0 auto 10px;
3361
+ }
3362
+ .vup-reconnect-msg {
3363
+ font-size: 12px;
3364
+ color: var(--color-text-muted);
3365
+ }
3366
+
3367
+ /* Error state */
3368
+ .vup-error {
3369
+ font-size: 12px;
3370
+ color: var(--color-error, #ef4444);
3371
+ margin: 0;
3372
+ line-height: 1.5;
3373
+ }
3374
+
3375
+
@@ -49,6 +49,22 @@ const I18n = (() => {
49
49
  "modal.yes": "Yes",
50
50
  "modal.no": "No",
51
51
 
52
+ // ── Version / Upgrade ──
53
+ "upgrade.desc": "A new version is available. It will install in the background — you can keep using the app.",
54
+ "upgrade.btn.upgrade": "Upgrade Now",
55
+ "upgrade.btn.cancel": "Cancel",
56
+ "upgrade.btn.restart": "↻ Restart Now",
57
+ "upgrade.installing": "Installing…",
58
+ "upgrade.done": "Upgrade complete!",
59
+ "upgrade.failed": "Upgrade failed. Please try again.",
60
+ "upgrade.reconnecting": "Restarting server…",
61
+ "upgrade.restart.success": "✓ Restarted successfully!",
62
+ "upgrade.tooltip.upgrading": "Upgrading — click to see progress",
63
+ "upgrade.tooltip.new": "v{{latest}} available — click to upgrade",
64
+ "upgrade.tooltip.ok": "v{{current}} (up to date)",
65
+ "upgrade.tooltip.needs_restart": "Upgrade complete — click to restart",
66
+ "upgrade.tooltip.done": "Restarted successfully",
67
+
52
68
  // ── Tasks panel ──
53
69
  "tasks.title": "Scheduled Tasks",
54
70
  "tasks.subtitle": "Manage and schedule automated tasks for your assistant",
@@ -260,6 +276,22 @@ const I18n = (() => {
260
276
  "modal.yes": "确认",
261
277
  "modal.no": "取消",
262
278
 
279
+ // ── Version / Upgrade ──
280
+ "upgrade.desc": "有新版本可用,将在后台安装,升级期间可继续使用。",
281
+ "upgrade.btn.upgrade": "立即升级",
282
+ "upgrade.btn.cancel": "取消",
283
+ "upgrade.btn.restart": "↻ 立即重启",
284
+ "upgrade.installing": "安装中…",
285
+ "upgrade.done": "升级完成!",
286
+ "upgrade.failed": "升级失败,请重试。",
287
+ "upgrade.reconnecting": "服务重启中…",
288
+ "upgrade.restart.success": "✓ 重启成功!",
289
+ "upgrade.tooltip.upgrading": "升级中,点击查看进度",
290
+ "upgrade.tooltip.new": "v{{latest}} 可用,点击升级",
291
+ "upgrade.tooltip.ok": "v{{current}}(已是最新)",
292
+ "upgrade.tooltip.needs_restart": "升级完成,点击重启",
293
+ "upgrade.tooltip.done": "重启成功",
294
+
263
295
  // ── Tasks panel ──
264
296
  "tasks.title": "定时任务",
265
297
  "tasks.subtitle": "管理和调度助手的自动化任务",
@@ -107,6 +107,14 @@
107
107
  <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
108
108
  </svg>
109
109
  <span data-i18n="sidebar.settings">Settings</span>
110
+ <!-- Version badge: inline in settings row, right-aligned -->
111
+ <span id="version-badge" class="version-badge" style="display:none">
112
+ <span id="version-text" class="version-text"></span>
113
+ <span id="version-update-dot" class="version-update-dot" style="display:none"></span>
114
+ <span id="version-restart-dot" class="version-restart-dot" style="display:none"></span>
115
+ <span id="version-spinner" class="version-spinner" style="display:none"></span>
116
+ <span id="version-done-check" class="version-done-check" style="display:none">✓</span>
117
+ </span>
110
118
  </button>
111
119
  </div>
112
120
  </aside>
@@ -467,6 +475,8 @@
467
475
  </div>
468
476
  </div>
469
477
 
478
+
479
+
470
480
  <script src="/marked.min.js"></script>
471
481
  <script src="/i18n.js"></script>
472
482
  <script src="/theme.js"></script>
@@ -478,6 +488,7 @@
478
488
  <script src="/settings.js"></script>
479
489
  <script src="/onboard.js"></script>
480
490
  <script src="/brand.js"></script>
491
+ <script src="/version.js"></script>
481
492
  <script src="/app.js"></script>
482
493
  </body>
483
494
  </html>
@@ -0,0 +1,362 @@
1
+ // ── Version — version check and upgrade flow ───────────────────────────────
2
+ //
3
+ // Badge states:
4
+ // (none) → up-to-date, muted version text
5
+ // has-update → amber pulsing dot: new version available
6
+ // is-upgrading → spinning ring: gem install in progress
7
+ // needs-restart → orange bouncing dot: upgrade done, waiting for restart
8
+ // upgrade-done → green check: restarted & reconnected successfully
9
+ //
10
+ // Flow:
11
+ // 1. Page load → checkVersion() → badge shows version number
12
+ // 2. needs_update: badge shows amber pulsing dot
13
+ // 3. Click badge → fixed popover (confirm state)
14
+ // 4. Click "Upgrade" → popover → progress state (live log)
15
+ // 5. upgrade_complete (success) → badge: needs-restart; popover: restart button
16
+ // 6. Click "Restart" → /api/restart → popover: reconnecting spinner
17
+ // → poll /api/version until server back → badge: upgrade-done (green ✓) → reload
18
+ // ─────────────────────────────────────────────────────────────────────────
19
+
20
+ const Version = (() => {
21
+ // ── State ──────────────────────────────────────────────────────────────
22
+ let _current = null;
23
+ let _latest = null;
24
+ let _needsUpdate = false;
25
+ let _upgrading = false;
26
+ let _needsRestart = false; // upgrade done, waiting for restart
27
+ let _reconnecting = false; // restart sent, polling for server to come back
28
+ let _upgradeDone = false; // restarted and reconnected successfully
29
+ let _popoverOpen = false;
30
+ let _reconnectTimer = null;
31
+ let _logLines = [];
32
+
33
+ // ── DOM helpers ────────────────────────────────────────────────────────
34
+ const $ = id => document.getElementById(id);
35
+ const el = (tag, attrs = {}, ...children) => {
36
+ const e = document.createElement(tag);
37
+ Object.entries(attrs).forEach(([k, v]) => {
38
+ if (k === "className") e.className = v;
39
+ else if (k === "innerHTML") e.innerHTML = v;
40
+ else e.setAttribute(k, v);
41
+ });
42
+ children.forEach(c => c && e.appendChild(typeof c === "string" ? document.createTextNode(c) : c));
43
+ return e;
44
+ };
45
+
46
+ // ── Version check ──────────────────────────────────────────────────────
47
+ async function checkVersion() {
48
+ try {
49
+ const res = await fetch("/api/version");
50
+ if (!res.ok) return;
51
+ const data = await res.json();
52
+ _current = data.current;
53
+ _latest = data.latest;
54
+ _needsUpdate = !!data.needs_update;
55
+ _renderBadge();
56
+ } catch (e) {
57
+ console.warn("[Version] check failed:", e);
58
+ }
59
+ }
60
+
61
+ // ── Badge render ───────────────────────────────────────────────────────
62
+ function _renderBadge() {
63
+ const badge = $("version-badge");
64
+ const text = $("version-text");
65
+ const dot = $("version-update-dot");
66
+ const restartDot = $("version-restart-dot");
67
+ const check = $("version-done-check");
68
+ const spinner = $("version-spinner");
69
+ if (!badge || !text) return;
70
+
71
+ text.textContent = _current ? `v${_current}` : "";
72
+
73
+ // Reset all indicators
74
+ if (dot) dot.style.display = "none";
75
+ if (restartDot) restartDot.style.display = "none";
76
+ if (check) check.style.display = "none";
77
+ if (spinner) spinner.style.display = "none";
78
+ badge.className = "version-badge";
79
+
80
+ if (_upgrading) {
81
+ // Spinning ring: gem install running
82
+ badge.classList.add("is-upgrading");
83
+ badge.title = I18n.t("upgrade.tooltip.upgrading");
84
+ if (spinner) spinner.style.display = "inline-block";
85
+ } else if (_needsRestart) {
86
+ // Orange bouncing dot: upgrade done, please restart
87
+ badge.classList.add("needs-restart");
88
+ badge.title = I18n.t("upgrade.tooltip.needs_restart");
89
+ if (restartDot) restartDot.style.display = "inline-block";
90
+ } else if (_upgradeDone) {
91
+ // Green check: restarted successfully
92
+ badge.classList.add("upgrade-done");
93
+ badge.title = I18n.t("upgrade.tooltip.done");
94
+ if (check) check.style.display = "inline-block";
95
+ } else if (_needsUpdate) {
96
+ // Amber pulsing dot: new version available
97
+ badge.classList.add("has-update");
98
+ badge.title = I18n.t("upgrade.tooltip.new", { latest: _latest });
99
+ if (dot) dot.style.display = "inline-block";
100
+ } else {
101
+ badge.title = I18n.t("upgrade.tooltip.ok", { current: _current });
102
+ }
103
+
104
+ badge.style.display = "flex";
105
+ }
106
+
107
+ // ── Popover (fixed, positioned above badge) ────────────────────────────
108
+ function _getOrCreatePopover() {
109
+ let pop = $("version-upgrade-popover");
110
+ if (pop) return pop;
111
+
112
+ pop = el("div", { id: "version-upgrade-popover", className: "vup" });
113
+ document.body.appendChild(pop);
114
+ return pop;
115
+ }
116
+
117
+ function _positionPopover() {
118
+ const badge = $("version-badge");
119
+ const pop = $("version-upgrade-popover");
120
+ if (!badge || !pop) return;
121
+
122
+ const rect = badge.getBoundingClientRect();
123
+ // Appear above the badge, right-aligned to sidebar edge
124
+ pop.style.left = rect.left + "px";
125
+ pop.style.bottom = (window.innerHeight - rect.top + 8) + "px";
126
+ pop.style.top = "auto";
127
+ }
128
+
129
+ function _openPopover() {
130
+ if (_popoverOpen) { _positionPopover(); return; }
131
+ _popoverOpen = true;
132
+
133
+ const pop = _getOrCreatePopover();
134
+ pop.innerHTML = "";
135
+
136
+ if (_reconnecting) {
137
+ _renderReconnectState(pop);
138
+ } else if (_upgrading) {
139
+ _renderProgressState(pop);
140
+ } else if (_needsRestart) {
141
+ _renderDoneState(pop);
142
+ } else if (_upgradeDone) {
143
+ _renderDoneState(pop);
144
+ } else {
145
+ _renderConfirmState(pop);
146
+ }
147
+
148
+ pop.style.display = "block";
149
+ _positionPopover();
150
+
151
+ // Animate in
152
+ requestAnimationFrame(() => pop.classList.add("vup--visible"));
153
+ }
154
+
155
+ function _closePopover() {
156
+ // Don't allow closing while upgrading or waiting for server to come back
157
+ if (_upgrading || _reconnecting) return;
158
+ const pop = $("version-upgrade-popover");
159
+ if (!pop) return;
160
+ pop.classList.remove("vup--visible");
161
+ setTimeout(() => {
162
+ pop.style.display = "none";
163
+ _popoverOpen = false;
164
+ }, 180);
165
+ }
166
+
167
+ // ── Popover states ─────────────────────────────────────────────────────
168
+
169
+ /** State 1: confirm upgrade */
170
+ function _renderConfirmState(pop) {
171
+ pop.innerHTML = `
172
+ <p class="vup-desc">${I18n.t("upgrade.desc")}</p>
173
+ <p class="vup-versions">v${_current} <span class="vup-arrow">→</span> v${_latest}</p>
174
+ <div class="vup-actions">
175
+ <button id="vup-btn-upgrade" class="vup-btn-primary">${I18n.t("upgrade.btn.upgrade")}</button>
176
+ <button id="vup-btn-cancel" class="vup-btn-cancel">${I18n.t("upgrade.btn.cancel")}</button>
177
+ </div>
178
+ `;
179
+ $("vup-btn-upgrade").addEventListener("click", () => _startUpgrade(pop));
180
+ $("vup-btn-cancel").addEventListener("click", _closePopover);
181
+ }
182
+
183
+ /** State 2: upgrading — show live log */
184
+ function _renderProgressState(pop) {
185
+ pop.innerHTML = `
186
+ <div class="vup-progress-header">
187
+ <span class="vup-installing-dot"></span>
188
+ <span class="vup-installing-label">${I18n.t("upgrade.installing")}</span>
189
+ </div>
190
+ <pre id="vup-log" class="vup-log"></pre>
191
+ `;
192
+ // Replay any logs already received
193
+ const logEl = $("vup-log");
194
+ if (logEl && _logLines.length) {
195
+ logEl.textContent = _logLines.join("\n");
196
+ logEl.scrollTop = logEl.scrollHeight;
197
+ }
198
+ }
199
+
200
+ /** State 3: done — show restart button */
201
+ function _renderDoneState(pop) {
202
+ pop.innerHTML = `
203
+ <div class="vup-done-header">
204
+ <span class="vup-done-icon">✓</span>
205
+ <span>${I18n.t("upgrade.done")}</span>
206
+ </div>
207
+ <button id="vup-btn-restart" class="vup-btn-restart">${I18n.t("upgrade.btn.restart")}</button>
208
+ `;
209
+ $("vup-btn-restart").addEventListener("click", _startRestart);
210
+ }
211
+
212
+ /** State 4: reconnecting after restart */
213
+ function _renderReconnectState(pop) {
214
+ pop.innerHTML = `
215
+ <div class="vup-reconnect">
216
+ <div class="vup-reconnect-spinner"></div>
217
+ <p class="vup-reconnect-msg">${I18n.t("upgrade.reconnecting")}</p>
218
+ </div>
219
+ `;
220
+ }
221
+
222
+ // ── Upgrade ────────────────────────────────────────────────────────────
223
+ async function _startUpgrade(pop) {
224
+ if (_upgrading || _upgradeDone) return;
225
+ _upgrading = true;
226
+ _logLines = [];
227
+ _renderBadge();
228
+ _renderProgressState(pop);
229
+
230
+ try {
231
+ await fetch("/api/version/upgrade", { method: "POST" });
232
+ } catch (e) {
233
+ console.warn("[Version] upgrade request failed:", e);
234
+ _upgrading = false;
235
+ _renderBadge();
236
+ }
237
+ }
238
+
239
+ // ── Restart ────────────────────────────────────────────────────────────
240
+ async function _startRestart() {
241
+ _reconnecting = true;
242
+
243
+ // Ensure popover is open and showing the reconnect spinner
244
+ const pop = _getOrCreatePopover();
245
+ _renderReconnectState(pop);
246
+ if (!_popoverOpen) {
247
+ _popoverOpen = true;
248
+ pop.style.display = "block";
249
+ _positionPopover();
250
+ requestAnimationFrame(() => pop.classList.add("vup--visible"));
251
+ }
252
+
253
+ try {
254
+ fetch("/api/restart", { method: "POST" }).catch(() => {});
255
+ } catch (_) {}
256
+
257
+ _waitForReconnect();
258
+ }
259
+
260
+ function _waitForReconnect() {
261
+ if (_reconnectTimer) clearInterval(_reconnectTimer);
262
+ setTimeout(() => {
263
+ _reconnectTimer = setInterval(async () => {
264
+ try {
265
+ const res = await fetch("/api/version", { cache: "no-store" });
266
+ if (res.ok) {
267
+ clearInterval(_reconnectTimer);
268
+ _reconnectTimer = null;
269
+ // Server is back — close popover, badge → green check, then reload
270
+ _reconnecting = false;
271
+ _needsRestart = false;
272
+ _upgradeDone = true;
273
+ _renderBadge();
274
+ _closePopover();
275
+ setTimeout(() => window.location.reload(), 800);
276
+ }
277
+ } catch (_) { /* server not yet up */ }
278
+ }, 2000);
279
+ }, 2500);
280
+ }
281
+
282
+ // ── WebSocket events ───────────────────────────────────────────────────
283
+ function _handleWsEvent(event) {
284
+ if (event.type === "upgrade_log") {
285
+ const line = event.line || "";
286
+ _logLines.push(line);
287
+ // Append to live log if popover is open
288
+ const logEl = $("vup-log");
289
+ if (logEl) {
290
+ logEl.textContent += (logEl.textContent ? "\n" : "") + line;
291
+ logEl.scrollTop = logEl.scrollHeight;
292
+ }
293
+ } else if (event.type === "upgrade_complete") {
294
+ _upgrading = false;
295
+ _needsUpdate = false;
296
+ if (event.success) {
297
+ _needsRestart = true; // badge: orange bouncing dot
298
+ _upgradeDone = false;
299
+ }
300
+ _renderBadge();
301
+ // Morph popover to done/error state
302
+ const pop = $("version-upgrade-popover");
303
+ if (pop && _popoverOpen) {
304
+ if (event.success) {
305
+ _renderDoneState(pop);
306
+ } else {
307
+ pop.innerHTML = `<p class="vup-error">${I18n.t("upgrade.failed")}</p>`;
308
+ }
309
+ }
310
+ }
311
+ }
312
+
313
+ // ── Init ───────────────────────────────────────────────────────────────
314
+ function init() {
315
+ const badge = $("version-badge");
316
+ if (badge) {
317
+ badge.addEventListener("click", e => {
318
+ if (!_current) return;
319
+ // Up-to-date: no action, let the click fall through to settings btn
320
+ if (!_needsUpdate && !_upgrading && !_needsRestart && !_reconnecting && !_upgradeDone) return;
321
+
322
+ // Actionable: stop propagation so settings panel doesn't open
323
+ e.stopPropagation();
324
+
325
+ // During reconnect, badge click just keeps popover visible (no toggle)
326
+ if (_reconnecting) { if (!_popoverOpen) _openPopover(); return; }
327
+
328
+ if (_popoverOpen) {
329
+ _closePopover();
330
+ } else {
331
+ _openPopover();
332
+ }
333
+ });
334
+ }
335
+
336
+ // Close on outside click — but not while upgrading or reconnecting
337
+ document.addEventListener("click", e => {
338
+ if (!e.target.closest("#version-badge") && !e.target.closest("#version-upgrade-popover")) {
339
+ if (_popoverOpen && !_upgrading && !_reconnecting) _closePopover();
340
+ }
341
+ });
342
+
343
+ // Reposition on window resize
344
+ window.addEventListener("resize", () => {
345
+ if (_popoverOpen) _positionPopover();
346
+ });
347
+
348
+ if (typeof WS !== "undefined") {
349
+ WS.onEvent(_handleWsEvent);
350
+ }
351
+
352
+ checkVersion();
353
+ }
354
+
355
+ if (document.readyState === "loading") {
356
+ document.addEventListener("DOMContentLoaded", init);
357
+ } else {
358
+ init();
359
+ }
360
+
361
+ return { checkVersion };
362
+ })();
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.9
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy
@@ -394,6 +394,7 @@ files:
394
394
  - lib/clacky/web/skills.js
395
395
  - lib/clacky/web/tasks.js
396
396
  - lib/clacky/web/theme.js
397
+ - lib/clacky/web/version.js
397
398
  - lib/clacky/web/ws.js
398
399
  - scripts/install.sh
399
400
  - scripts/uninstall.sh