openclacky 1.3.5 → 1.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/lib/clacky/agent/message_compressor.rb +32 -8
  4. data/lib/clacky/agent/message_compressor_helper.rb +113 -12
  5. data/lib/clacky/agent.rb +8 -4
  6. data/lib/clacky/agent_profile.rb +121 -53
  7. data/lib/clacky/brand_config.rb +309 -6
  8. data/lib/clacky/cli.rb +4 -58
  9. data/lib/clacky/default_extensions/coding/ext.yml +18 -0
  10. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +95 -0
  11. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +254 -0
  12. data/lib/clacky/default_extensions/ext-studio/ext.yml +32 -0
  13. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +1269 -0
  14. data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +71 -0
  15. data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +74 -0
  16. data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +65 -0
  17. data/lib/clacky/default_extensions/general/ext.yml +18 -0
  18. data/lib/clacky/default_extensions/git/ext.yml +12 -0
  19. data/lib/clacky/{default_agents/_panels/git/panel.js → default_extensions/git/panels/git/view.js} +1 -2
  20. data/lib/clacky/default_extensions/meeting/{handler.rb → api/handler.rb} +78 -26
  21. data/lib/clacky/default_extensions/meeting/ext.yml +15 -0
  22. data/lib/clacky/default_extensions/meeting/{meeting.js → panels/meeting/view.js} +266 -89
  23. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +2 -1
  24. data/lib/clacky/default_extensions/time_machine/ext.yml +12 -0
  25. data/lib/clacky/{default_agents/_panels/time_machine/panel.js → default_extensions/time_machine/panels/time_machine/view.js} +58 -47
  26. data/lib/clacky/default_skills/channel-manager/discord_setup.rb +1 -1
  27. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +83 -18
  28. data/lib/clacky/{api_extension.rb → extension/api_extension.rb} +27 -11
  29. data/lib/clacky/extension/api_loader.rb +136 -0
  30. data/lib/clacky/extension/cli_commands.rb +226 -0
  31. data/lib/clacky/{server/api_extension_dispatcher.rb → extension/dispatcher.rb} +22 -9
  32. data/lib/clacky/extension/hook_loader.rb +77 -0
  33. data/lib/clacky/extension/loader.rb +483 -0
  34. data/lib/clacky/extension/packager.rb +226 -0
  35. data/lib/clacky/{patch_loader.rb → extension/patch_loader.rb} +45 -0
  36. data/lib/clacky/extension/scaffold/template_renderer.rb +64 -0
  37. data/lib/clacky/extension/scaffold/templates/full/README.md.erb +48 -0
  38. data/lib/clacky/extension/scaffold/templates/full/agents/designer.md +5 -0
  39. data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
  40. data/lib/clacky/extension/scaffold/templates/full/channels/noop.rb.erb +40 -0
  41. data/lib/clacky/extension/scaffold/templates/full/ext.yml.erb +41 -0
  42. data/lib/clacky/extension/scaffold/templates/full/hooks/audit.rb +9 -0
  43. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +194 -0
  44. data/lib/clacky/extension/scaffold/templates/full/patches/audit.rb +15 -0
  45. data/lib/clacky/extension/scaffold/templates/full/skills/__slug__-skill/SKILL.md.erb +8 -0
  46. data/lib/clacky/extension/scaffold/templates/hello/api/handler.rb.erb +9 -0
  47. data/lib/clacky/extension/scaffold/templates/hello/ext.yml.erb +12 -0
  48. data/lib/clacky/extension/scaffold/templates/hello/panels/hello/view.js.erb +31 -0
  49. data/lib/clacky/extension/scaffold.rb +55 -0
  50. data/lib/clacky/extension/verifier.rb +196 -0
  51. data/lib/clacky/identity.rb +70 -0
  52. data/lib/clacky/locales/en.rb +19 -1
  53. data/lib/clacky/locales/zh.rb +19 -1
  54. data/lib/clacky/platform_http_client.rb +18 -15
  55. data/lib/clacky/server/channel/extension_adapter_loader.rb +32 -0
  56. data/lib/clacky/server/channel.rb +6 -0
  57. data/lib/clacky/server/http_server.rb +435 -263
  58. data/lib/clacky/server/session_registry.rb +59 -3
  59. data/lib/clacky/session_manager.rb +35 -4
  60. data/lib/clacky/skill_loader.rb +28 -1
  61. data/lib/clacky/ui2/layout_manager.rb +0 -5
  62. data/lib/clacky/ui2/progress_handle.rb +0 -3
  63. data/lib/clacky/ui2/ui_controller.rb +9 -15
  64. data/lib/clacky/version.rb +1 -1
  65. data/lib/clacky/web/app.css +782 -554
  66. data/lib/clacky/web/app.js +63 -15
  67. data/lib/clacky/web/auth.js +2 -0
  68. data/lib/clacky/web/components/notify.js +2 -0
  69. data/lib/clacky/web/components/sidebar.js +2 -8
  70. data/lib/clacky/web/core/ext.js +316 -79
  71. data/lib/clacky/web/features/backup/store.js +24 -0
  72. data/lib/clacky/web/features/backup/view.js +49 -23
  73. data/lib/clacky/web/features/brand/view.js +13 -5
  74. data/lib/clacky/web/features/extensions/store.js +184 -0
  75. data/lib/clacky/web/features/extensions/view.js +389 -0
  76. data/lib/clacky/web/features/new-session/store.js +186 -0
  77. data/lib/clacky/web/features/new-session/view.js +319 -0
  78. data/lib/clacky/web/features/share/view.js +63 -14
  79. data/lib/clacky/web/features/skills/store.js +3 -1
  80. data/lib/clacky/web/features/skills/view.js +1 -1
  81. data/lib/clacky/web/features/tasks/view.js +88 -27
  82. data/lib/clacky/web/features/trash/store.js +24 -6
  83. data/lib/clacky/web/features/trash/view.js +46 -9
  84. data/lib/clacky/web/features/workspace/store.js +2 -0
  85. data/lib/clacky/web/features/workspace/view.js +0 -1
  86. data/lib/clacky/web/i18n.js +140 -108
  87. data/lib/clacky/web/index.html +190 -214
  88. data/lib/clacky/web/sessions.js +99 -304
  89. data/lib/clacky/web/settings.js +1 -1
  90. data/lib/clacky/web/skills.js +2 -0
  91. data/lib/clacky/web/theme.js +2 -0
  92. data/lib/clacky/web/ws-dispatcher.js +2 -1
  93. data/lib/clacky/web/ws.js +2 -0
  94. data/lib/clacky.rb +21 -10
  95. metadata +55 -24
  96. data/benchmark/runner.rb +0 -441
  97. data/lib/clacky/api_extension_loader.rb +0 -168
  98. data/lib/clacky/default_agents/SOUL.md +0 -3
  99. data/lib/clacky/default_agents/USER.md +0 -1
  100. data/lib/clacky/default_agents/coding/profile.yml +0 -5
  101. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  102. data/lib/clacky/default_agents/general/profile.yml +0 -2
  103. data/lib/clacky/default_extensions/meeting/meta.yml +0 -3
  104. data/lib/clacky/web/features/creator/store.js +0 -81
  105. data/lib/clacky/web/features/creator/view.js +0 -380
  106. /data/lib/clacky/{default_agents → default_extensions/coding/agents}/coding/system_prompt.md +0 -0
  107. /data/lib/clacky/{default_agents → default_extensions/general/agents}/general/system_prompt.md +0 -0
  108. /data/lib/clacky/{default_agents/base_prompt.md → prompts/base.md} +0 -0
@@ -41,6 +41,7 @@ const Sessions = (() => {
41
41
  let _searchToken = 0; // monotonic counter; in-flight requests check against this
42
42
  let _cronView = false; // are we in the cron sub-view?
43
43
  let _cronCount = 0; // total cron sessions from server
44
+ let _latestCronUpdatedAt = null; // updated_at of the newest cron session (for virtual entry sort position)
44
45
  // ── Cron sub-view independent pagination (commit 2) ──────────────────────
45
46
  // The folded cron sub-view paginates *independently* of the outer list so
46
47
  // that "Load more" inside it never advances the outer list's cursor, and so
@@ -292,6 +293,25 @@ const Sessions = (() => {
292
293
 
293
294
  let _userScrolledUp = false; // true if user manually scrolled away from bottom
294
295
 
296
+ let _agentsById = null;
297
+ let _agentsLoadingPromise = null;
298
+
299
+ function _ensureAgentsLoaded() {
300
+ if (_agentsById) return Promise.resolve(_agentsById);
301
+ if (_agentsLoadingPromise) return _agentsLoadingPromise;
302
+ _agentsLoadingPromise = fetch("/api/agents")
303
+ .then(r => (r.ok ? r.json() : { agents: [] }))
304
+ .then(data => {
305
+ const map = {};
306
+ ((data && data.agents) || []).forEach(a => { map[a.id] = a; });
307
+ _agentsById = map;
308
+ return map;
309
+ })
310
+ .catch(() => (_agentsById = {}))
311
+ .finally(() => { _agentsLoadingPromise = null; });
312
+ return _agentsLoadingPromise;
313
+ }
314
+
295
315
  function _isAtBottom(container) {
296
316
  if (!container) return false;
297
317
  const threshold = 150;
@@ -449,76 +469,9 @@ const Sessions = (() => {
449
469
  // we call addEventListener directly (no ?. / no `if` guards). If any is
450
470
  // missing, it means HTML and JS drifted and we want the loud error.
451
471
  function _initNewSessionControls() {
452
- // Split button: main (quick create)
453
472
  document.getElementById("btn-new-session-inline")
454
- .addEventListener("click", () => Sessions.create("general"));
455
-
456
- // Split button: arrow — show dropdown on hover, hide when leaving the whole wrap
457
- const arrow = document.getElementById("btn-new-session-arrow");
458
- const wrap = arrow.closest(".btn-split-wrap");
459
- const dd = document.getElementById("new-session-dropdown");
460
-
461
- arrow.addEventListener("mouseenter", () => { dd.hidden = false; });
462
- dd.addEventListener("mouseenter", () => { dd.hidden = false; });
463
- wrap.addEventListener("mouseleave", () => { dd.hidden = true; });
464
- dd.addEventListener("mouseleave", () => { dd.hidden = true; });
465
-
466
- // Dropdown item "Advanced Options…"
467
- document.addEventListener("click", (e) => {
468
- if (e.target && e.target.id === "btn-new-session-modal") {
469
- dd.hidden = true;
470
- Sessions.openNewSessionModal();
471
- } else if (!wrap.contains(e.target)) {
472
- dd.hidden = true;
473
- }
474
- });
475
-
476
- // Welcome screen "+ New Session" button
477
- document.getElementById("btn-welcome-new")
478
- .addEventListener("click", () => Sessions.create("general"));
479
-
480
- // Welcome screen starter chips: create a session, then prefill the prompt
481
- document.querySelectorAll(".chip[data-welcome-prompt]").forEach((chip) => {
482
- chip.addEventListener("click", async () => {
483
- await Sessions.create("general");
484
- const prompt = I18n.t(chip.dataset.welcomePrompt);
485
- const input = document.getElementById("user-input");
486
- if (input && prompt) {
487
- input.value = prompt;
488
- input.focus();
489
- input.dispatchEvent(new Event("input", { bubbles: true }));
490
- }
491
- });
492
- });
493
-
494
- // Modal: cancel / create / overlay click
495
- document.getElementById("new-session-cancel")
496
- .addEventListener("click", () => Sessions.closeNewSessionModal());
497
- document.getElementById("new-session-create")
498
- .addEventListener("click", () => Sessions.createFromModal());
499
- document.getElementById("new-session-modal")
500
- .addEventListener("click", (e) => {
501
- // Only close when the click lands on the overlay itself, not on
502
- // the inner dialog panel.
503
- if (e.target.id === "new-session-modal") {
504
- Sessions.closeNewSessionModal();
505
- }
506
- });
507
-
508
- // Working-directory browse button → reuses the session picker in
509
- // session-less mode (no session exists yet, so it browses via /api/dirs).
510
- const browseBtn = document.getElementById("new-session-browse-btn");
511
- if (browseBtn) {
512
- browseBtn.addEventListener("click", async () => {
513
- const dirInput = document.getElementById("new-session-directory");
514
- const start = dirInput ? dirInput.value.trim() : "";
515
- const picked = await window.openDirectoryPicker(start, null);
516
- if (picked && dirInput) dirInput.value = picked;
517
- });
518
- }
473
+ .addEventListener("click", () => { location.hash = "#new"; });
519
474
 
520
- // Load-more sessions button is rendered dynamically by renderList(),
521
- // so we listen via event delegation.
522
475
  document.addEventListener("click", (e) => {
523
476
  if (e.target && e.target.id === "btn-load-more-sessions") {
524
477
  Sessions.loadMore();
@@ -638,11 +591,11 @@ const Sessions = (() => {
638
591
 
639
592
  function _addImageFile(file) {
640
593
  if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
641
- alert(`Unsupported image type: ${file.type}\nSupported: PNG, JPEG, GIF, WEBP`);
594
+ alert(I18n.t("chat.file.unsupportedType", { type: file.type }));
642
595
  return;
643
596
  }
644
597
  if (file.size > MAX_IMAGE_SIZE) {
645
- alert(`Image too large: ${file.name} (max 5 MB)`);
598
+ alert(I18n.t("chat.file.imageTooLarge", { name: file.name, max: "5 MB" }));
646
599
  return;
647
600
  }
648
601
  const seq = ++_imageSeq;
@@ -654,12 +607,12 @@ const Sessions = (() => {
654
607
  _pendingImages.push({ dataUrl, name: displayName, mimeType: file.type === "image/png" ? "image/png" : "image/jpeg", seq });
655
608
  _renderAttachmentPreviews();
656
609
  })
657
- .catch(err => alert(`Image processing failed: ${err.message}`));
610
+ .catch(err => alert(I18n.t("chat.file.processFailed", { msg: err.message })));
658
611
  }
659
612
 
660
613
  function _addGenericFile(file) {
661
614
  if (file.size > MAX_FILE_BYTES) {
662
- alert(`File too large: ${file.name} (max 32 MB)`);
615
+ alert(I18n.t("chat.file.tooLarge", { name: file.name, max: "32 MB" }));
663
616
  return;
664
617
  }
665
618
  // Upload file to server via HTTP — only the path is returned, no base64 in memory
@@ -668,7 +621,7 @@ const Sessions = (() => {
668
621
  fetch("/api/upload", { method: "POST", body: formData })
669
622
  .then(r => r.json())
670
623
  .then(data => {
671
- if (!data.ok) { alert(`Upload failed: ${data.error}`); return; }
624
+ if (!data.ok) { alert(I18n.t("chat.file.uploadFailed", { msg: data.error })); return; }
672
625
  _pendingFiles.push({
673
626
  name: data.name,
674
627
  path: data.path,
@@ -2165,11 +2118,22 @@ const Sessions = (() => {
2165
2118
  ? `<span class="session-badge session-badge--${s.source}">${I18n.t(badgeKey)}</span>`
2166
2119
  : "";
2167
2120
 
2168
- // Coding profile badge (agent_profile === "coding"). Neutral styling so
2169
- // it lives peacefully with the source badge and the status dot.
2170
- const codingBadgeHtml = s.agent_profile === "coding"
2171
- ? `<span class="session-badge session-badge--coding">${I18n.t("sessions.badge.coding")}</span>`
2172
- : "";
2121
+ // Agent profile badge: any non-general profile gets a badge. Coding
2122
+ // (built-in) keeps its i18n label + hollow neutral style; extension /
2123
+ // custom agents fall back to their configured title (or profile id).
2124
+ let agentBadgeHtml = "";
2125
+ const profile = s.agent_profile;
2126
+ if (profile && profile !== "general") {
2127
+ if (profile === "coding") {
2128
+ agentBadgeHtml = `<span class="session-badge session-badge--coding">${I18n.t("sessions.badge.coding")}</span>`;
2129
+ } else {
2130
+ const meta = _agentsById && _agentsById[profile];
2131
+ const isZh = I18n.lang && I18n.lang().startsWith("zh");
2132
+ const label = (meta && ((isZh && meta.title_zh) || meta.title)) || profile;
2133
+ const safeLabel = escapeHtml(label);
2134
+ agentBadgeHtml = `<span class="session-badge session-badge--agent" title="${safeLabel}">${safeLabel}</span>`;
2135
+ }
2136
+ }
2173
2137
 
2174
2138
  // Pin icon (always visible for pinned sessions)
2175
2139
  const pinIcon = s.pinned ? `<span class="session-pin-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" style="transform:rotate(45deg);display:block"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/></svg></span>` : "";
@@ -2186,7 +2150,7 @@ const Sessions = (() => {
2186
2150
 
2187
2151
  el.innerHTML = `
2188
2152
  <div class="session-body">
2189
- <div class="session-name">${dotHtml}<span class="session-name__text">${nameHtml}</span>${badgeHtml}${codingBadgeHtml}${pinIcon}</div>
2153
+ <div class="session-name">${dotHtml}<span class="session-name__text">${nameHtml}</span>${badgeHtml}${agentBadgeHtml}${pinIcon}</div>
2190
2154
  <div class="session-meta">${metaText}</div>
2191
2155
  ${snippetHtml}
2192
2156
  </div>
@@ -2324,6 +2288,7 @@ const Sessions = (() => {
2324
2288
  _initComposer();
2325
2289
  _initSearch();
2326
2290
  _initMessageHistory();
2291
+ _ensureAgentsLoaded().then(() => Sessions.renderList());
2327
2292
  // Re-render session list (badges/labels) when the user switches language
2328
2293
  document.addEventListener("langchange", () => Sessions.renderList());
2329
2294
 
@@ -2378,11 +2343,12 @@ const Sessions = (() => {
2378
2343
  // ── List management ───────────────────────────────────────────────────
2379
2344
 
2380
2345
  /** Populate list from initial session_list WS event (connect only). */
2381
- setAll(list, hasMore = false, cronCount = 0) {
2346
+ setAll(list, hasMore = false, cronCount = 0, latestCronUpdatedAt = null) {
2382
2347
  _sessions.length = 0;
2383
2348
  _sessions.push(...list);
2384
- _hasMore = !!hasMore;
2385
- _cronCount = cronCount;
2349
+ _hasMore = !!hasMore;
2350
+ _cronCount = cronCount;
2351
+ _latestCronUpdatedAt = latestCronUpdatedAt || null;
2386
2352
  },
2387
2353
 
2388
2354
  /** Insert a newly created session into the local list. */
@@ -2433,6 +2399,14 @@ const Sessions = (() => {
2433
2399
  } else {
2434
2400
  _sessions.unshift({ id, ...fields });
2435
2401
  }
2402
+ // Keep _latestCronUpdatedAt current when a cron session is patched/added.
2403
+ const updated = _sessions.find(s => s.id === id);
2404
+ if (updated?.source === "cron") {
2405
+ const t = updated.updated_at || updated.created_at;
2406
+ if (t && (!_latestCronUpdatedAt || t > _latestCronUpdatedAt)) {
2407
+ _latestCronUpdatedAt = t;
2408
+ }
2409
+ }
2436
2410
  },
2437
2411
 
2438
2412
  /** Remove a session from the list (from session_deleted event). */
@@ -2460,21 +2434,17 @@ const Sessions = (() => {
2460
2434
  Sessions.renderList();
2461
2435
 
2462
2436
  try {
2463
- // Cursor: oldest activity time (updated_at, falling back to
2464
- // created_at) in the current list, EXCLUDING pinned sessions. The
2465
- // backend always returns ALL pinned sessions on the first page (they
2466
- // bypass pagination), so their time is irrelevant for the cursor.
2467
- // Including them here would cause the cursor to jump too far back and
2468
- // skip sessions between the oldest pinned one and the real
2469
- // last-loaded non-pinned row.
2437
+ // Cursor: oldest activity time of non-pinned, non-cron sessions.
2438
+ // Cron sessions are excluded from the normal list pagination and have
2439
+ // their own independent cursor (_cronBefore) in the sub-view.
2470
2440
  const oldest = _sessions.reduce((min, s) => {
2471
- if (s.pinned) return min; // ignore pinned
2441
+ if (s.pinned || s.source === "cron") return min;
2472
2442
  const t = s.updated_at || s.created_at;
2473
2443
  if (!t) return min;
2474
2444
  return (!min || t < min) ? t : min;
2475
2445
  }, null);
2476
2446
 
2477
- const params = new URLSearchParams({ limit: "20" });
2447
+ const params = new URLSearchParams({ limit: "20", exclude_type: "cron" });
2478
2448
  if (oldest) params.set("before", oldest);
2479
2449
  if (_filter.q) params.set("q", _filter.q);
2480
2450
  if (_filter.date) params.set("date", _filter.date);
@@ -2487,8 +2457,9 @@ const Sessions = (() => {
2487
2457
  (data.sessions || []).forEach(s => {
2488
2458
  if (!_sessions.find(x => x.id === s.id)) _sessions.push(s);
2489
2459
  });
2490
- _hasMore = !!data.has_more;
2491
- _cronCount = data.cron_count || 0;
2460
+ _hasMore = !!data.has_more;
2461
+ _cronCount = data.cron_count ?? _cronCount;
2462
+ if (data.latest_cron_updated_at) _latestCronUpdatedAt = data.latest_cron_updated_at;
2492
2463
  } catch (e) {
2493
2464
  console.error("loadMore error:", e);
2494
2465
  } finally {
@@ -2884,35 +2855,28 @@ const Sessions = (() => {
2884
2855
  }
2885
2856
  cronSessions.forEach(s => _renderSessionItem(list, s));
2886
2857
  } else if (_cronCount > 0) {
2887
- // Normal list view: the cron group entry is a *virtual* row that
2888
- // participates in the time-ordering instead of being pinned to the
2889
- // top. We walk the already-sorted `visible` list and drop the group
2890
- // entry at the position of the newest (first-encountered) cron
2891
- // session so it sits exactly where the latest folded cron task
2892
- // would sort by created_at. Pinning is intentionally ignored for the
2893
- // entry itself: a pinned cron session only takes effect *inside* the
2894
- // folded sub-view, not on the outer entry.
2895
- const cronHasRunning = cronSessions.some(s => s.status === "running");
2858
+ // Normal list view: virtual cron group entry positioned by _latestCronUpdatedAt.
2859
+ // Walk the sorted non-cron list and insert the entry at the first row
2860
+ // whose time is older than the newest cron session's updated_at.
2861
+ const cronHasRunning = _sessions.some(s => s.source === "cron" && s.status === "running");
2862
+ const nonCron = visible.filter(s => s.source !== "cron");
2896
2863
  let cronEntryRendered = false;
2897
- visible.forEach(s => {
2898
- if (s.source === "cron") {
2899
- // Render the group entry once, at the first (newest) cron slot;
2900
- // skip every individual cron session in the flat list.
2901
- if (!cronEntryRendered) {
2864
+
2865
+ nonCron.forEach(s => {
2866
+ if (!cronEntryRendered && !s.pinned && _latestCronUpdatedAt) {
2867
+ const t = s.updated_at || s.created_at;
2868
+ if (!t || t < _latestCronUpdatedAt) {
2902
2869
  _renderCronGroupItem(list, _cronCount, cronHasRunning);
2903
2870
  cronEntryRendered = true;
2904
2871
  }
2905
- return;
2906
2872
  }
2907
2873
  _renderSessionItem(list, s);
2908
2874
  });
2909
- // NOTE: we intentionally do NOT append a fallback entry when no cron
2910
- // session is loaded yet. The group entry is a virtual row that must
2911
- // ride along with pagination: it only appears at the sort slot of the
2912
- // first loaded cron session. If the newest cron lives on a later page,
2913
- // the entry simply shows up once that page is loaded — rather than
2914
- // being forced onto the bottom of page 1 (which would mislead the
2915
- // sort position and miss the running state of an unpaged cron).
2875
+ // If no insertion point found (all normal sessions are newer, or list is empty),
2876
+ // append the entry at the bottom of the loaded rows.
2877
+ if (!cronEntryRendered) {
2878
+ _renderCronGroupItem(list, _cronCount, cronHasRunning);
2879
+ }
2916
2880
  } else {
2917
2881
  // Normal list view, no cron sessions
2918
2882
  visible.forEach(s => _renderSessionItem(list, s));
@@ -2937,14 +2901,22 @@ const Sessions = (() => {
2937
2901
  // has scrolled away to browse other sessions.
2938
2902
  if (scrollToActive) {
2939
2903
  const activeEl = list.querySelector(".session-item.active");
2940
- if (activeEl) {
2941
- // If the active session is the very first item, scroll to top of the sidebar
2942
- // container so sticky headers / expanded panels don't obscure it.
2943
- if (activeEl === list.firstElementChild) {
2944
- const sidebarList = document.getElementById("sidebar-list");
2945
- if (sidebarList) sidebarList.scrollTop = 0;
2946
- } else {
2947
- activeEl.scrollIntoView({ block: "nearest" });
2904
+ const sidebarList = document.getElementById("sidebar-list");
2905
+ if (activeEl && sidebarList) {
2906
+ // Only scroll when the active row is not already fully visible.
2907
+ // The "Sessions" divider is position:sticky at the top of
2908
+ // #sidebar-list, so the usable viewport starts *below* it — treat
2909
+ // that as the top edge, otherwise a row tucked under the header
2910
+ // reads as "visible" and never gets revealed.
2911
+ const header = sidebarList.querySelector(".sidebar-divider");
2912
+ const headerH = (header && header.offsetParent) ? header.offsetHeight : 0;
2913
+ const listRect = sidebarList.getBoundingClientRect();
2914
+ const rowRect = activeEl.getBoundingClientRect();
2915
+ const topEdge = listRect.top + headerH;
2916
+ if (rowRect.top < topEdge) {
2917
+ sidebarList.scrollTop += rowRect.top - topEdge;
2918
+ } else if (rowRect.bottom > listRect.bottom) {
2919
+ sidebarList.scrollTop += rowRect.bottom - listRect.bottom;
2948
2920
  }
2949
2921
  }
2950
2922
  }
@@ -3189,7 +3161,7 @@ const Sessions = (() => {
3189
3161
  /** Update the session info bar below the chat header with current session metadata. */
3190
3162
  updateInfoBar(s) {
3191
3163
  this._lastSession = s;
3192
- if (window.Workspace) Workspace.onSession(s);
3164
+ if (Clacky.Workspace) Clacky.Workspace.onSession(s);
3193
3165
  if (!s) {
3194
3166
  // Hide all spans when no session
3195
3167
  ["sib-id", "sib-status", "sib-dir", "sib-mode", "sib-model", "sib-reasoning", "sib-tasks", "sib-cost"].forEach(id => {
@@ -4028,187 +4000,8 @@ const Sessions = (() => {
4028
4000
  _pendingMessage = null;
4029
4001
  return msg;
4030
4002
  },
4031
-
4032
- // ── New Session Modal ──────────────────────────────────────────────────
4033
-
4034
- /** Open the New Session modal with configuration options. */
4035
- openNewSessionModal() {
4036
- const modal = $("new-session-modal");
4037
- if (!modal) return;
4038
-
4039
- // Populate model dropdown from configured models
4040
- _populateModelDropdown();
4041
-
4042
- // Set default working directory to an absolute path (home/clacky_workspace).
4043
- const dirInput = $("new-session-directory");
4044
- if (dirInput && !dirInput.value) {
4045
- fetch("/api/dirs")
4046
- .then(r => r.ok ? r.json() : null)
4047
- .then(data => {
4048
- const home = data && data.home;
4049
- if (home && !dirInput.value) {
4050
- dirInput.value = home.replace(/\/+$/, "") + "/clacky_workspace";
4051
- }
4052
- })
4053
- .catch(() => {});
4054
- }
4055
-
4056
- // Setup agent type change listener to show/hide init project checkbox
4057
- const agentSelect = $("new-session-agent");
4058
- const initProjectField = $("new-session-init-project-field");
4059
-
4060
- if (agentSelect && initProjectField) {
4061
- // Set initial state based on current selection
4062
- initProjectField.style.display = agentSelect.value === "coding" ? "block" : "none";
4063
-
4064
- // Listen for changes
4065
- agentSelect.addEventListener("change", function() {
4066
- initProjectField.style.display = this.value === "coding" ? "block" : "none";
4067
- });
4068
- }
4069
-
4070
- // Show modal
4071
- modal.style.display = "flex";
4072
- },
4073
-
4074
- /** Close the New Session modal. */
4075
- closeNewSessionModal() {
4076
- const modal = $("new-session-modal");
4077
- if (modal) modal.style.display = "none";
4078
- },
4079
-
4080
- /** Create session from modal form data. */
4081
- async createFromModal() {
4082
- const agentSelect = $("new-session-agent");
4083
- const nameInput = $("new-session-name");
4084
- const modelSelect = $("new-session-model");
4085
- const dirInput = $("new-session-directory");
4086
- const initCheckbox = $("new-session-init-project");
4087
- const createBtn = $("new-session-create");
4088
-
4089
- const agentProfile = agentSelect ? agentSelect.value : "general";
4090
- const customName = nameInput ? nameInput.value.trim() : "";
4091
- // The dropdown's value is the model's stable runtime id (see
4092
- // _populateModelDropdown). Using the id — not the model *name* — lets
4093
- // the backend switch to the right full model entry (api_key, base_url,
4094
- // anthropic_format) instead of mutating the current default entry's
4095
- // name in place, which caused "unknown model <name>" errors when the
4096
- // chosen model belonged to a different provider than the default.
4097
- const selectedModelId = modelSelect ? modelSelect.value : "";
4098
- const workingDir = dirInput ? dirInput.value.trim() : "";
4099
- const initProject = initCheckbox ? initCheckbox.checked : false;
4100
-
4101
- // Auto-generate name if not provided
4102
- let name = customName;
4103
- if (!name) {
4104
- const maxN = _sessions.reduce((max, s) => {
4105
- const m = s.name.match(/^Session (\d+)$/);
4106
- return m ? Math.max(max, parseInt(m[1], 10)) : max;
4107
- }, 0);
4108
- name = "Session " + (maxN + 1);
4109
- }
4110
-
4111
- if (createBtn) createBtn.disabled = true;
4112
-
4113
- try {
4114
- const payload = {
4115
- name,
4116
- agent_profile: agentProfile,
4117
- source: "manual"
4118
- };
4119
-
4120
- // Add optional fields
4121
- if (workingDir) payload.working_dir = workingDir;
4122
- if (selectedModelId) payload.model_id = selectedModelId;
4123
-
4124
- const res = await fetch("/api/sessions", {
4125
- method: "POST",
4126
- headers: { "Content-Type": "application/json" },
4127
- body: JSON.stringify(payload)
4128
- });
4129
- const data = await res.json();
4130
-
4131
- if (!res.ok) {
4132
- const msg = data.error || "unknown error";
4133
- const friendly = res.status === 409
4134
- ? I18n.t("sessions.dirNotEmpty")
4135
- : I18n.t("sessions.createError") + msg;
4136
- alert(friendly);
4137
- if (createBtn) createBtn.disabled = false;
4138
- return;
4139
- }
4140
-
4141
- const session = data.session;
4142
- if (!session) return;
4143
-
4144
- // Close modal and reset form
4145
- Sessions.closeNewSessionModal();
4146
- if (nameInput) nameInput.value = "";
4147
- if (dirInput) dirInput.value = "";
4148
- if (initCheckbox) initCheckbox.checked = false;
4149
-
4150
- // Add to list and select
4151
- Sessions.add(session);
4152
- Sessions.renderList();
4153
- Sessions.select(session.id);
4154
-
4155
- // If init project was checked, send /new command
4156
- if (initProject) {
4157
- Sessions.setPendingMessage(session.id, "/new");
4158
- }
4159
- } catch (e) {
4160
- alert(I18n.t("sessions.createError") + e.message);
4161
- } finally {
4162
- if (createBtn) createBtn.disabled = false;
4163
- }
4164
- },
4165
4003
  };
4166
4004
 
4167
- // ── Helper: Populate model dropdown ────────────────────────────────────────
4168
-
4169
- async function _populateModelDropdown() {
4170
- const modelSelect = $("new-session-model");
4171
- if (!modelSelect) return;
4172
-
4173
- try {
4174
- const res = await fetch("/api/config");
4175
- const data = await res.json();
4176
- const models = data.models || [];
4177
-
4178
- modelSelect.innerHTML = "";
4179
-
4180
- if (models.length === 0) {
4181
- const opt = document.createElement("option");
4182
- opt.value = "";
4183
- opt.textContent = "No models configured";
4184
- modelSelect.appendChild(opt);
4185
- return;
4186
- }
4187
-
4188
- // Add each configured model (CLI-style format).
4189
- // The option's value is the model's stable runtime id — not the bare
4190
- // model name — so the backend can switch to the exact model entry
4191
- // (with matching api_key / base_url / anthropic_format) when the user
4192
- // chooses a non-default model. See createFromModal + build_session.
4193
- models.forEach(m => {
4194
- const opt = document.createElement("option");
4195
- opt.value = m.id || "";
4196
-
4197
- // Format: [default] abs-claude-sonnet-4-5 (clacky...8825)
4198
- const typeBadge = m.type === "default" ? "[default] " : "";
4199
- const label = `${typeBadge}${m.model} (${m.api_key_masked})`;
4200
- opt.textContent = label;
4201
-
4202
- // Pre-select default model
4203
- if (m.type === "default") opt.selected = true;
4204
- modelSelect.appendChild(opt);
4205
- });
4206
- } catch (e) {
4207
- console.error("Failed to load models:", e);
4208
- modelSelect.innerHTML = '<option value="">Error loading models</option>';
4209
- }
4210
- }
4211
-
4212
4005
  return Sessions;
4213
4006
  })();
4214
4007
 
@@ -5681,7 +5474,7 @@ const Sessions = (() => {
5681
5474
  // ── Session Info Bar Reasoning Effort Switcher ────────────────────────────
5682
5475
  (function() {
5683
5476
  let _isOpen = false;
5684
- const LEVELS = ["off", "low", "medium", "high"];
5477
+ const LEVELS = ["off", "low", "medium", "high", "xhigh"];
5685
5478
 
5686
5479
  document.addEventListener("click", async (e) => {
5687
5480
  const el = e.target.closest("#sib-reasoning");
@@ -5790,3 +5583,5 @@ document.addEventListener("currencychange", () => {
5790
5583
  scrollTimer = setTimeout(() => sidebarList.classList.remove("is-scrolling"), 1000);
5791
5584
  }, { passive: true });
5792
5585
  })();
5586
+
5587
+ Clacky.Sessions = Sessions;
@@ -20,7 +20,7 @@ const Settings = (() => {
20
20
  _loadBrand();
21
21
  _loadBrowserStatus();
22
22
  _initNetworkSettings();
23
- if (window.Backup) Backup.load();
23
+ if (Clacky.Backup) Clacky.Backup.load();
24
24
  _applyAboutTabVisibility();
25
25
  }
26
26
 
@@ -452,3 +452,5 @@ const SkillAC = (() => {
452
452
  handleKey: _handleKey,
453
453
  };
454
454
  })();
455
+
456
+ Clacky.SkillAC = SkillAC;
@@ -30,11 +30,13 @@ const Theme = (() => {
30
30
  // Icon shows what you'd switch TO, not what you are on.
31
31
  if (theme === "light") {
32
32
  // In light mode → show moon (click to go dark)
33
+ headerToggle.title = I18n.t("header.theme.toDark");
33
34
  headerToggle.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
34
35
  <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
35
36
  </svg>`;
36
37
  } else {
37
38
  // In dark mode → show sun (click to go light)
39
+ headerToggle.title = I18n.t("header.theme.toLight");
38
40
  headerToggle.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
39
41
  <circle cx="12" cy="12" r="4"/>
40
42
  <path d="M12 2v2"/>
@@ -180,7 +180,7 @@ WS.onEvent(ev => {
180
180
 
181
181
  // ── Session list ───────────────────────────────────────────────────
182
182
  case "session_list": {
183
- Sessions.setAll(ev.sessions || [], !!ev.has_more, ev.cron_count || 0);
183
+ Sessions.setAll(ev.sessions || [], !!ev.has_more, ev.cron_count || 0, ev.latest_cron_updated_at || null);
184
184
  Sessions.renderList();
185
185
 
186
186
  // Restore URL hash once on initial connect; ignore subsequent session_list events.
@@ -281,6 +281,7 @@ WS.onEvent(ev => {
281
281
  }
282
282
 
283
283
  case "session_deleted":
284
+ if (window.Clacky && Clacky.ext) Clacky.ext.notifySessionRemoved(ev.session_id);
284
285
  Sessions.remove(ev.session_id);
285
286
  if (ev.session_id === Sessions.activeId) Router.navigate("welcome");
286
287
  Sessions.renderList();
data/lib/clacky/web/ws.js CHANGED
@@ -126,3 +126,5 @@ const WS = (() => {
126
126
  get ready() { return _ready; },
127
127
  };
128
128
  })();
129
+
130
+ Clacky.WS = WS;