openclacky 1.5.6 → 1.5.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +18 -1
  3. data/.clackyrules +25 -1
  4. data/CHANGELOG.md +26 -0
  5. data/CONTRIBUTING.md +9 -0
  6. data/Rakefile +15 -0
  7. data/docs/design-emit-and-background-skills.md +253 -0
  8. data/docs/mcp-architecture.md +48 -9
  9. data/docs/mcp.example.json +9 -0
  10. data/docs/superpowers/plans/2026-08-09-chatcut-extension-bridge.md +191 -0
  11. data/docs/superpowers/plans/2026-08-09-native-mcp-oauth.md +243 -0
  12. data/docs/superpowers/specs/2026-08-09-mcp-oauth-extension-fallback-design.md +115 -0
  13. data/lib/clacky/agent/cost_tracker.rb +17 -2
  14. data/lib/clacky/agent/hook_manager.rb +14 -3
  15. data/lib/clacky/agent/memory_updater.rb +17 -18
  16. data/lib/clacky/agent/message_compressor_helper.rb +23 -0
  17. data/lib/clacky/agent/session_serializer.rb +136 -22
  18. data/lib/clacky/agent/skill_auto_creator.rb +1 -2
  19. data/lib/clacky/agent/skill_evolution.rb +11 -7
  20. data/lib/clacky/agent/skill_manager.rb +20 -18
  21. data/lib/clacky/agent/skill_reflector.rb +3 -6
  22. data/lib/clacky/agent.rb +111 -20
  23. data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
  24. data/lib/clacky/cli.rb +4 -0
  25. data/lib/clacky/client.rb +1 -1
  26. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +13 -5
  27. data/lib/clacky/extension/api_extension.rb +11 -3
  28. data/lib/clacky/fanout.rb +100 -0
  29. data/lib/clacky/locales/en.rb +2 -0
  30. data/lib/clacky/locales/zh.rb +2 -0
  31. data/lib/clacky/mcp/cli_commands.rb +61 -0
  32. data/lib/clacky/mcp/client.rb +18 -2
  33. data/lib/clacky/mcp/connection_manager.rb +85 -0
  34. data/lib/clacky/mcp/http_transport.rb +25 -9
  35. data/lib/clacky/mcp/oauth/authorization_manager.rb +257 -0
  36. data/lib/clacky/mcp/oauth/config.rb +51 -0
  37. data/lib/clacky/mcp/oauth/credential_store.rb +82 -0
  38. data/lib/clacky/mcp/oauth/session.rb +88 -0
  39. data/lib/clacky/message_format/anthropic.rb +27 -13
  40. data/lib/clacky/message_history.rb +32 -1
  41. data/lib/clacky/providers.rb +11 -5
  42. data/lib/clacky/server/http_server.rb +42 -22
  43. data/lib/clacky/server/session_registry.rb +11 -15
  44. data/lib/clacky/server/web_ui_controller.rb +1 -1
  45. data/lib/clacky/session_manager.rb +13 -11
  46. data/lib/clacky/shell_hook_loader.rb +14 -8
  47. data/lib/clacky/tools/invoke_skill.rb +3 -2
  48. data/lib/clacky/tools/terminal.rb +11 -4
  49. data/lib/clacky/ui2/progress_handle.rb +27 -3
  50. data/lib/clacky/ui2/ui_controller.rb +206 -6
  51. data/lib/clacky/ui_interface.rb +13 -3
  52. data/lib/clacky/utils/arguments_parser.rb +73 -4
  53. data/lib/clacky/utils/model_pricing.rb +21 -0
  54. data/lib/clacky/version.rb +1 -1
  55. data/lib/clacky/web/app.css +10 -3
  56. data/lib/clacky/web/features/new-session/view.js +19 -2
  57. data/lib/clacky/web/i18n.js +22 -8
  58. data/lib/clacky/web/index.html +7 -11
  59. data/lib/clacky/web/projects.js +26 -0
  60. data/lib/clacky/web/sessions.js +169 -119
  61. data/lib/clacky/web/settings.js +1 -0
  62. data/lib/clacky/web/ws-dispatcher.js +61 -34
  63. data/lib/clacky.rb +2 -0
  64. metadata +12 -1
@@ -156,14 +156,14 @@
156
156
  </div>
157
157
  </div>
158
158
 
159
- <!-- Cron view header (hidden by default, shown when viewing cron sessions) -->
160
- <div id="cron-view-header" class="sidebar-divider" style="display:none">
161
- <button id="btn-cron-back" class="btn-icon-sm" title="Back" aria-label="Back to session list">
159
+ <!-- Folded group sub-view header (hidden by default; title filled by Sessions) -->
160
+ <div id="group-view-header" class="sidebar-divider" style="display:none">
161
+ <button id="btn-group-back" class="btn-icon-sm" title="Back" aria-label="Back to session list">
162
162
  <svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
163
163
  <path d="M10 3L5 8l5 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
164
164
  </svg>
165
165
  </button>
166
- <span data-i18n="sessions.cronGroup">Scheduled Tasks</span>
166
+ <span data-group-title></span>
167
167
  <span></span><!-- spacer -->
168
168
  </div>
169
169
 
@@ -1079,10 +1079,12 @@
1079
1079
  <div class="settings-network-url">
1080
1080
  <label class="settings-network-url-label" for="settings-search-provider" data-i18n="settings.search.provider">Provider</label>
1081
1081
  <div class="settings-network-url-row">
1082
- <select id="settings-search-provider" class="field-input">
1082
+ <select id="settings-search-provider" class="field-select">
1083
1083
  <option value="" data-i18n="settings.search.builtin">Built-in (DuckDuckGo / Bing)</option>
1084
1084
  </select>
1085
+ <button type="button" id="btn-save-search" class="btn-settings-action" data-i18n="settings.search.save">Save</button>
1085
1086
  </div>
1087
+ <div id="settings-search-status" class="model-test-result"></div>
1086
1088
  </div>
1087
1089
  <div class="settings-network-url" id="search-key-wrap" style="display:none">
1088
1090
  <label class="settings-network-url-label" for="settings-search-key" data-i18n="settings.search.key">API Key</label>
@@ -1091,12 +1093,6 @@
1091
1093
  <button type="button" id="btn-test-search" class="btn-settings-action" data-i18n="settings.search.test">Test</button>
1092
1094
  </div>
1093
1095
  </div>
1094
- <div class="settings-network-url">
1095
- <div class="settings-network-url-row">
1096
- <button type="button" id="btn-save-search" class="btn-settings-action" data-i18n="settings.search.save">Save</button>
1097
- </div>
1098
- <div id="settings-search-status" class="model-test-result"></div>
1099
- </div>
1100
1096
  <p class="settings-network-desc" data-i18n="settings.search.custom">Add your own provider by dropping a script into ~/.clacky/searchers/ — see tavily.rb for the interface.</p>
1101
1097
  </div>
1102
1098
  </section>
@@ -287,6 +287,8 @@ const Projects = (() => {
287
287
  });
288
288
  const data = await res.json();
289
289
  if (res.ok && data.session) {
290
+ Sessions.add(data.session);
291
+ renderSection();
290
292
  Sessions.select(data.session.id);
291
293
  }
292
294
  } catch (err) {
@@ -540,6 +542,7 @@ const Projects = (() => {
540
542
  };
541
543
 
542
544
  addItem("projects.menu.edit", "Edit", false, () => promptEdit(projectId));
545
+ addItem("projects.menu.deleteSessions", "Delete Sessions", false, () => promptDeleteSessions(projectId));
543
546
  addItem("projects.menu.delete", "Delete", true, () => promptDelete(projectId));
544
547
 
545
548
  // Position near anchor
@@ -1079,6 +1082,29 @@ const Projects = (() => {
1079
1082
  }
1080
1083
  }
1081
1084
 
1085
+ /** Confirm and delete all sessions in a project (keeps the project itself). */
1086
+ async function promptDeleteSessions(id) {
1087
+ const project = find(id);
1088
+ if (!project) return;
1089
+
1090
+ const confirmed = await Modal.confirm(
1091
+ I18n.t("projects.deleteSessions.confirm", { name: project.name })
1092
+ );
1093
+ if (!confirmed) return;
1094
+
1095
+ try {
1096
+ const res = await fetch(`/api/projects/${id}/sessions`, { method: "DELETE" });
1097
+ const data = await res.json();
1098
+ if (!res.ok) throw new Error(data.error || "Failed to delete sessions");
1099
+
1100
+ Sessions.removeByProjectId(id);
1101
+ Sessions.renderList();
1102
+ renderSection();
1103
+ } catch (err) {
1104
+ console.error("[Projects] delete sessions error:", err);
1105
+ }
1106
+ }
1107
+
1082
1108
  /** Confirm and delete a project. */
1083
1109
  async function promptDelete(id) {
1084
1110
  const project = find(id);
@@ -39,19 +39,50 @@ const Sessions = (() => {
39
39
  // { nameIds: Set<id>, contentIds: Set<id>, contentLoaded: bool }
40
40
  let _searchSplit = null;
41
41
  let _searchToken = 0; // monotonic counter; in-flight requests check against this
42
- let _cronView = false; // are we in the cron sub-view?
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)
45
- // ── Cron sub-view independent pagination (commit 2) ──────────────────────
46
- // The folded cron sub-view paginates *independently* of the outer list so
47
- // that "Load more" inside it never advances the outer list's cursor, and so
48
- // all cron sessions can be loaded even when they're sparse across the mixed
49
- // outer pages. Cron rows fetched here are pushed into the shared `_sessions`
50
- // array (with dedup), so WS updates / patch / remove keep working unchanged;
51
- // we only track a separate cursor + hasMore/loading flags for the sub-view.
52
- let _cronBefore = null; // cursor: oldest cron created_at loaded into the sub-view
53
- let _cronHasMore = false; // are there older cron sessions to load?
54
- let _cronLoadingMore = false;
42
+ // ── Folded sidebar groups (cron / ext) ───────────────────────────────────
43
+ // Sessions whose `source` matches a folded group are pulled out of the main
44
+ // sidebar list and represented by a single virtual entry that opens a
45
+ // sub-view. Each group paginates *independently* of the outer list so that
46
+ // "Load more" inside a sub-view never advances the outer list's cursor, and
47
+ // so all of a group's sessions can be loaded even when they're sparse across
48
+ // the mixed outer pages. Rows fetched here are pushed into the shared
49
+ // `_sessions` array (with dedup), so WS updates / patch / remove keep working
50
+ // unchanged; only the cursor + hasMore/loading flags are per-group.
51
+ //
52
+ // Sessions assigned to a project never count toward a group — they render in
53
+ // the project section instead.
54
+ const GROUP_SOURCES = ["cron", "ext"];
55
+ const _groups = new Map(GROUP_SOURCES.map(source => [source, {
56
+ count: 0, // total sessions in this group (server-side, unpaginated)
57
+ latestUpdatedAt: null, // newest activity — decides the virtual entry's sort position
58
+ before: null, // cursor: oldest activity time loaded into the sub-view
59
+ hasMore: false,
60
+ loadingMore: false,
61
+ }]));
62
+ let _groupView = null; // source of the group sub-view we're in, or null
63
+ const SOURCE_BADGE_KEYS = {
64
+ cron: "sessions.badge.cron",
65
+ ext: "sessions.badge.ext",
66
+ channel: "sessions.badge.channel",
67
+ setup: "sessions.badge.setup",
68
+ };
69
+ const GROUP_ICONS = { cron: "📋", ext: "🧩" };
70
+
71
+ // Group a session belongs to, or null when it renders in the main list.
72
+ // Project-assigned sessions always render in the project section.
73
+ const _groupOf = s => (s && !s.project_id && _groups.get(s.source)) || null;
74
+
75
+ // Merge server-reported group stats (REST/WS `groups` payload) into local state.
76
+ function _applyGroupStats(stats) {
77
+ if (!stats) return;
78
+ _groups.forEach((g, source) => {
79
+ const s = stats[source];
80
+ if (!s) return;
81
+ if (s.count != null) g.count = s.count;
82
+ if (s.latest_updated_at) g.latestUpdatedAt = s.latest_updated_at;
83
+ });
84
+ }
85
+
55
86
  let _pendingRunTaskId = null; // session_id waiting to send "run_task" after subscribe
56
87
  let _pendingMessage = null; // { session_id, content } — slash command to send after subscribe
57
88
  // Buffer for tool_stdout lines that arrive before history has finished rendering.
@@ -1358,6 +1389,17 @@ const Sessions = (() => {
1358
1389
  // historyCtx = { group: DOMElement|null, lastItem: DOMElement|null }
1359
1390
  if (!historyCtx) historyCtx = { group: null, lastItem: null };
1360
1391
 
1392
+ // Custom extension events replayed from history. The host draws nothing;
1393
+ // forwarding them to the ext bus lets an extension panel rebuild the state
1394
+ // it had built from the live events before the reload.
1395
+ if (typeof ev.type === "string" && ev.type.startsWith("ext.")) {
1396
+ if (window.Clacky && Clacky.ext) {
1397
+ const sid = ev.session_id;
1398
+ Clacky.ext.emit(ev.type, { sessionId: sid, ...ev, replayed: true });
1399
+ }
1400
+ return;
1401
+ }
1402
+
1361
1403
  switch (ev.type) {
1362
1404
  case "history_user_message": {
1363
1405
  // Collapse any open tool group from the previous round
@@ -2126,13 +2168,13 @@ const Sessions = (() => {
2126
2168
  }
2127
2169
 
2128
2170
  // Build the unified load-more button.
2129
- function _makeLoadMoreBtn(cron = false) {
2171
+ function _makeLoadMoreBtn(groupSource = null) {
2130
2172
  const btn = document.createElement("button");
2131
2173
  btn.className = "btn-load-more-sessions";
2132
- const loading = cron ? _cronLoadingMore : _loadingMore;
2174
+ const loading = groupSource ? _groups.get(groupSource).loadingMore : _loadingMore;
2133
2175
  btn.disabled = loading;
2134
2176
  btn.textContent = loading ? I18n.t("sessions.loadingMore") : I18n.t("sessions.loadMore");
2135
- btn.onclick = () => cron ? Sessions.loadMoreCron() : Sessions.loadMore();
2177
+ btn.onclick = () => groupSource ? Sessions.loadMoreGroup(groupSource) : Sessions.loadMore();
2136
2178
  return btn;
2137
2179
  }
2138
2180
 
@@ -2205,10 +2247,7 @@ const Sessions = (() => {
2205
2247
  // because source is "how the session was created" and coding is "what
2206
2248
  // agent runs inside". Using a muted badge for coding avoids drawing
2207
2249
  // attention away from the running-state dot, which is more important.
2208
- const badgeKey = s.source === "cron" ? "sessions.badge.cron"
2209
- : s.source === "channel" ? "sessions.badge.channel"
2210
- : s.source === "setup" ? "sessions.badge.setup"
2211
- : null;
2250
+ const badgeKey = SOURCE_BADGE_KEYS[s.source] || null;
2212
2251
  const badgeHtml = badgeKey
2213
2252
  ? `<span class="session-badge session-badge--${s.source}">${I18n.t(badgeKey)}</span>`
2214
2253
  : "";
@@ -2285,52 +2324,56 @@ const Sessions = (() => {
2285
2324
  container.appendChild(el);
2286
2325
  }
2287
2326
 
2288
- // ── Cron group entry (renders the folded "Scheduled Tasks" entry) ─────
2327
+ // ── Folded group entry (e.g. "Scheduled Tasks" / "Extensions") ────────
2289
2328
  // `hasRunning` mirrors a normal session's status dot: show the green
2290
- // (running) dot only when any folded cron session is currently running,
2291
- // and render no dot at all when the group is idle.
2292
- function _renderCronGroupItem(container, count, hasRunning = false) {
2329
+ // (running) dot only when any folded session in the group is currently
2330
+ // running, and render no dot at all when the group is idle.
2331
+ function _renderGroupItem(container, source, count, hasRunning = false) {
2293
2332
  const el = document.createElement("div");
2294
- el.className = "session-item cron-group-item";
2333
+ el.className = `session-item group-item group-item--${source}`;
2295
2334
  const dotHtml = hasRunning ? `<span class="session-dot dot-running"></span>` : "";
2296
2335
  el.innerHTML = `
2297
2336
  <div class="session-body">
2298
2337
  <div class="session-name">
2299
2338
  ${dotHtml}
2300
- <span class="session-name__text">📋 ${I18n.t("sessions.cronGroup")} (${count})</span>
2339
+ <span class="session-name__text">${GROUP_ICONS[source]} ${I18n.t(`sessions.group.${source}`)} (${count})</span>
2301
2340
  </div>
2302
- <div class="session-meta">${I18n.t("sessions.cronGroupMeta", { n: count })}</div>
2341
+ <div class="session-meta">${I18n.t("sessions.groupMeta", { n: count })}</div>
2303
2342
  </div>
2304
2343
  `;
2305
2344
  el.onclick = () => {
2306
- _cronView = true;
2345
+ _groupView = source;
2307
2346
  Sessions.renderList();
2308
- // Method A: always (re-)load the freshest first page of cron sessions
2309
- // on entering the sub-view. Independent cursor — never touches the
2310
- // outer list's pagination.
2311
- Sessions.loadMoreCron({ reset: true });
2347
+ // Always (re-)load the freshest first page on entering the sub-view.
2348
+ // Independent cursor — never touches the outer list's pagination.
2349
+ Sessions.loadMoreGroup(source, { reset: true });
2312
2350
  };
2313
2351
  container.appendChild(el);
2314
2352
  }
2315
2353
 
2316
2354
  // ── Chat-section header visibility ────────────────────────────────────
2317
- function _updateChatHeader(isCronView) {
2355
+ function _updateChatHeader(groupSource) {
2318
2356
  const chatSection = document.getElementById("chat-section");
2319
2357
  if (!chatSection) return;
2320
2358
 
2321
2359
  const normalHeader = chatSection.querySelector(":scope > .sidebar-divider:first-of-type");
2322
- const cronHeader = document.getElementById("cron-view-header");
2360
+ const groupHeader = document.getElementById("group-view-header");
2323
2361
 
2324
- if (isCronView) {
2362
+ if (groupSource) {
2325
2363
  if (normalHeader) normalHeader.style.display = "none";
2326
- if (cronHeader) cronHeader.style.display = "";
2364
+ if (groupHeader) {
2365
+ groupHeader.style.display = "";
2366
+ const title = groupHeader.querySelector("[data-group-title]");
2367
+ if (title) title.textContent = `${GROUP_ICONS[groupSource]} ${I18n.t(`sessions.group.${groupSource}`)}`;
2368
+ }
2327
2369
  } else {
2328
2370
  if (normalHeader) normalHeader.style.display = "";
2329
- if (cronHeader) cronHeader.style.display = "none";
2371
+ if (groupHeader) groupHeader.style.display = "none";
2330
2372
  }
2331
2373
  }
2332
2374
 
2333
2375
 
2376
+
2334
2377
  // ── Public API ─────────────────────────────────────────────────────────
2335
2378
  return {
2336
2379
  get all() { return _sessions; },
@@ -2400,10 +2443,10 @@ const Sessions = (() => {
2400
2443
  // Re-render session list (badges/labels) when the user switches language
2401
2444
  document.addEventListener("langchange", () => Sessions.renderList());
2402
2445
 
2403
- // Cron view back button
2404
- document.getElementById("btn-cron-back")
2446
+ // Group sub-view back button
2447
+ document.getElementById("btn-group-back")
2405
2448
  .addEventListener("click", () => {
2406
- _cronView = false;
2449
+ _groupView = null;
2407
2450
  Sessions.renderList();
2408
2451
  });
2409
2452
 
@@ -2453,19 +2496,20 @@ const Sessions = (() => {
2453
2496
  // ── List management ───────────────────────────────────────────────────
2454
2497
 
2455
2498
  /** Populate list from initial session_list WS event (connect only). */
2456
- setAll(list, hasMore = false, cronCount = 0, latestCronUpdatedAt = null) {
2499
+ setAll(list, hasMore = false, groups = {}) {
2457
2500
  _sessions.length = 0;
2458
2501
  _sessions.push(...list);
2459
- _hasMore = !!hasMore;
2460
- _cronCount = cronCount;
2461
- _latestCronUpdatedAt = latestCronUpdatedAt || null;
2502
+ _hasMore = !!hasMore;
2503
+ _groups.forEach(g => { g.count = 0; g.latestUpdatedAt = null; });
2504
+ _applyGroupStats(groups);
2462
2505
  },
2463
2506
 
2464
2507
  /** Insert a newly created session into the local list. */
2465
2508
  add(session) {
2466
2509
  if (!_sessions.find(s => s.id === session.id)) {
2467
2510
  _sessions.push(session);
2468
- if (session.source === "cron") _cronCount++;
2511
+ const g = _groupOf(session);
2512
+ if (g) g.count++;
2469
2513
  }
2470
2514
  },
2471
2515
 
@@ -2500,26 +2544,34 @@ const Sessions = (() => {
2500
2544
  * prepend it so the sidebar shows it immediately. */
2501
2545
  patch(id, fields) {
2502
2546
  const s = _sessions.find(s => s.id === id);
2547
+ const extra = _extraSessions.find(e => e.id === id);
2548
+ const before = s || extra;
2549
+ const wasGroup = _groupOf(before);
2550
+
2503
2551
  if (s) {
2504
2552
  Object.assign(s, fields);
2505
2553
  } else {
2506
2554
  // _extraSessions keeps the session findable without inserting it into
2507
2555
  // _sessions, which would pollute the loadMore cursor (sessions outside
2508
2556
  // the current page must not affect the pagination boundary).
2509
- const extra = _extraSessions.find(e => e.id === id);
2510
2557
  if (extra) {
2511
2558
  Object.assign(extra, fields);
2512
2559
  } else {
2513
2560
  _extraSessions.push({ id, ...fields });
2514
2561
  }
2515
2562
  }
2516
- // Keep _latestCronUpdatedAt current when a cron session is patched/added.
2517
- const updated = _sessions.find(s => s.id === id)
2518
- || _extraSessions.find(e => e.id === id);
2519
- if (updated?.source === "cron") {
2563
+
2564
+ const updated = s || _extraSessions.find(e => e.id === id);
2565
+ const isGroup = _groupOf(updated);
2566
+ if (wasGroup !== isGroup) {
2567
+ if (wasGroup) wasGroup.count = Math.max(0, wasGroup.count - 1);
2568
+ if (isGroup) isGroup.count++;
2569
+ }
2570
+
2571
+ if (isGroup) {
2520
2572
  const t = updated.updated_at || updated.created_at;
2521
- if (t && (!_latestCronUpdatedAt || t > _latestCronUpdatedAt)) {
2522
- _latestCronUpdatedAt = t;
2573
+ if (t && (!isGroup.latestUpdatedAt || t > isGroup.latestUpdatedAt)) {
2574
+ isGroup.latestUpdatedAt = t;
2523
2575
  }
2524
2576
  }
2525
2577
  },
@@ -2528,7 +2580,8 @@ const Sessions = (() => {
2528
2580
  remove(id) {
2529
2581
  const idx = _sessions.findIndex(s => s.id === id);
2530
2582
  if (idx !== -1) {
2531
- if (_sessions[idx].source === "cron") _cronCount = Math.max(0, _cronCount - 1);
2583
+ const g = _groupOf(_sessions[idx]);
2584
+ if (g) g.count = Math.max(0, g.count - 1);
2532
2585
  _sessions.splice(idx, 1);
2533
2586
  }
2534
2587
  // Clean up per-session progress state (timer + DOM + logical state)
@@ -2571,17 +2624,17 @@ const Sessions = (() => {
2571
2624
  Sessions.renderList();
2572
2625
 
2573
2626
  try {
2574
- // Cursor: oldest activity time of non-pinned, non-cron sessions.
2575
- // Cron sessions are excluded from the normal list pagination and have
2576
- // their own independent cursor (_cronBefore) in the sub-view.
2627
+ // Cursor: oldest activity time of non-pinned, non-grouped sessions.
2628
+ // Grouped sessions are excluded from the normal list pagination and
2629
+ // have their own independent cursor in each sub-view.
2577
2630
  const oldest = _sessions.reduce((min, s) => {
2578
- if (s.pinned || s.source === "cron") return min;
2631
+ if (s.pinned || _groupOf(s)) return min;
2579
2632
  const t = s.updated_at || s.created_at;
2580
2633
  if (!t) return min;
2581
2634
  return (!min || t < min) ? t : min;
2582
2635
  }, null);
2583
2636
 
2584
- const params = new URLSearchParams({ limit: "10", exclude_type: "cron" });
2637
+ const params = new URLSearchParams({ limit: "10", exclude_type: GROUP_SOURCES.join(",") });
2585
2638
  if (oldest) params.set("before", oldest);
2586
2639
  if (_filter.q) params.set("q", _filter.q);
2587
2640
  if (_filter.date) params.set("date", _filter.date);
@@ -2594,9 +2647,8 @@ const Sessions = (() => {
2594
2647
  (data.sessions || []).forEach(s => {
2595
2648
  if (!_sessions.find(x => x.id === s.id)) _sessions.push(s);
2596
2649
  });
2597
- _hasMore = !!data.has_more;
2598
- _cronCount = data.cron_count ?? _cronCount;
2599
- if (data.latest_cron_updated_at) _latestCronUpdatedAt = data.latest_cron_updated_at;
2650
+ _hasMore = !!data.has_more;
2651
+ _applyGroupStats(data.groups);
2600
2652
  } catch (e) {
2601
2653
  console.error("loadMore error:", e);
2602
2654
  } finally {
@@ -2607,25 +2659,26 @@ const Sessions = (() => {
2607
2659
  }
2608
2660
  },
2609
2661
 
2610
- /** Cron sub-view pagination — independent cursor, does NOT touch the outer
2611
- * list's `_hasMore` / `loadMore` cursor. Fetches the next page of cron
2612
- * sessions (type=cron) and pushes them into the shared `_sessions` array
2613
- * (deduped), so WS patch/remove/add keep working unchanged. Only the
2614
- * sub-view's own cursor + flags advance. Pass `reset:true` to start over
2615
- * from the newest cron page (used on entering the sub-view). */
2616
- async loadMoreCron({ reset = false } = {}) {
2617
- if (_cronLoadingMore) return;
2618
- if (!reset && !_cronHasMore) return;
2619
- _cronLoadingMore = true;
2620
- if (reset) { _cronBefore = null; _cronHasMore = false; }
2662
+ /** Group sub-view pagination — independent cursor, does NOT touch the outer
2663
+ * list's `_hasMore` / `loadMore` cursor. Fetches the next page of the
2664
+ * group's sessions (type=<source>) and pushes them into the shared
2665
+ * `_sessions` array (deduped), so WS patch/remove/add keep working
2666
+ * unchanged. Only the sub-view's own cursor + flags advance. Pass
2667
+ * `reset:true` to start over from the newest page (used on entering it). */
2668
+ async loadMoreGroup(source, { reset = false } = {}) {
2669
+ const group = _groups.get(source);
2670
+ if (!group || group.loadingMore) return;
2671
+ if (!reset && !group.hasMore) return;
2672
+ group.loadingMore = true;
2673
+ if (reset) { group.before = null; group.hasMore = false; }
2621
2674
 
2622
2675
  const sidebarList = document.getElementById("sidebar-list");
2623
2676
  const savedScrollTop = sidebarList ? sidebarList.scrollTop : 0;
2624
2677
  Sessions.renderList();
2625
2678
 
2626
2679
  try {
2627
- const params = new URLSearchParams({ limit: "10", type: "cron" });
2628
- if (_cronBefore) params.set("before", _cronBefore);
2680
+ const params = new URLSearchParams({ limit: "10", type: source });
2681
+ if (group.before) params.set("before", group.before);
2629
2682
 
2630
2683
  const res = await fetch(`/api/sessions?${params}`);
2631
2684
  if (!res.ok) return;
@@ -2635,7 +2688,7 @@ const Sessions = (() => {
2635
2688
  rows.forEach(s => {
2636
2689
  if (!_sessions.find(x => x.id === s.id)) _sessions.push(s);
2637
2690
  });
2638
- // Advance cursor to the oldest cron activity time in THIS batch
2691
+ // Advance cursor to the oldest activity time in THIS batch
2639
2692
  // (exclude pinned — backend returns all pinned on the first page,
2640
2693
  // bypassing pagination, so their time must not drag the cursor back).
2641
2694
  const oldest = rows.reduce((min, s) => {
@@ -2644,13 +2697,13 @@ const Sessions = (() => {
2644
2697
  if (!t) return min;
2645
2698
  return (!min || t < min) ? t : min;
2646
2699
  }, null);
2647
- if (oldest) _cronBefore = oldest;
2648
- _cronHasMore = !!data.has_more;
2649
- if (data.cron_count != null) _cronCount = data.cron_count;
2700
+ if (oldest) group.before = oldest;
2701
+ group.hasMore = !!data.has_more;
2702
+ _applyGroupStats(data.groups);
2650
2703
  } catch (e) {
2651
- console.error("loadMoreCron error:", e);
2704
+ console.error("loadMoreGroup error:", e);
2652
2705
  } finally {
2653
- _cronLoadingMore = false;
2706
+ group.loadingMore = false;
2654
2707
  Sessions.renderList();
2655
2708
  if (sidebarList) sidebarList.scrollTop = savedScrollTop;
2656
2709
  }
@@ -2995,59 +3048,56 @@ const Sessions = (() => {
2995
3048
  .filter(s => !s.project_id)
2996
3049
  .sort(byPinnedAndTime);
2997
3050
 
2998
- // ── Split cron vs non-cron for folding ───────────────────────────
2999
- const isCronView = _cronView;
3000
- const cronSessions = visible.filter(s => s.source === "cron");
3001
-
3002
- // Update chat-section header based on view mode
3003
- _updateChatHeader(isCronView);
3051
+ // ── Fold grouped sources (cron / ext) out of the main list ────────
3052
+ _updateChatHeader(_groupView);
3004
3053
 
3005
3054
  const list = $("session-list");
3006
3055
  list.innerHTML = "";
3007
3056
 
3008
- if (isCronView) {
3057
+ if (_groupView) {
3009
3058
  // We never call the outer loadMore() here, so the outer list's cursor
3010
3059
  // is left untouched. While the first page is in flight and nothing is
3011
3060
  // loaded yet, show a loading placeholder instead of the empty state.
3012
- if (cronSessions.length === 0 && _cronLoadingMore) {
3013
- list.innerHTML = `<div class="session-empty">${I18n.t("sessions.cronLoading")}</div>`;
3061
+ const rows = visible.filter(s => s.source === _groupView);
3062
+ if (rows.length === 0 && _groups.get(_groupView).loadingMore) {
3063
+ list.innerHTML = `<div class="session-empty">${I18n.t("sessions.groupLoading")}</div>`;
3014
3064
  return;
3015
3065
  }
3016
- cronSessions.forEach(s => _renderSessionItem(list, s));
3017
- } else if (_cronCount > 0) {
3018
- // Normal list view: virtual cron group entry positioned by _latestCronUpdatedAt.
3019
- // Walk the sorted non-cron list and insert the entry at the first row
3020
- // whose time is older than the newest cron session's updated_at.
3021
- const cronHasRunning = _sessions.some(s => s.source === "cron" && s.status === "running");
3022
- const nonCron = visible.filter(s => s.source !== "cron");
3023
- let cronEntryRendered = false;
3024
-
3025
- nonCron.forEach(s => {
3026
- if (!cronEntryRendered && !s.pinned && _latestCronUpdatedAt) {
3027
- const t = s.updated_at || s.created_at;
3028
- if (!t || t < _latestCronUpdatedAt) {
3029
- _renderCronGroupItem(list, _cronCount, cronHasRunning);
3030
- cronEntryRendered = true;
3031
- }
3032
- }
3066
+ rows.forEach(s => _renderSessionItem(list, s));
3067
+ } else {
3068
+ // Each non-empty group gets one virtual entry, positioned by its latest
3069
+ // activity: walk the ungrouped rows newest-first and flush any entry
3070
+ // that is newer than the row about to be rendered.
3071
+ const pending = GROUP_SOURCES
3072
+ .filter(source => _groups.get(source).count > 0)
3073
+ .sort((a, b) => (_groups.get(b).latestUpdatedAt || "").localeCompare(_groups.get(a).latestUpdatedAt || ""));
3074
+
3075
+ const shouldFlush = (source, s) => {
3076
+ const latest = _groups.get(source).latestUpdatedAt;
3077
+ if (!latest || s.pinned) return false;
3078
+ const t = s.updated_at || s.created_at;
3079
+ return !t || t < latest;
3080
+ };
3081
+ const renderGroup = source => {
3082
+ const hasRunning = _sessions.some(s => s.source === source && !s.project_id && s.status === "running");
3083
+ _renderGroupItem(list, source, _groups.get(source).count, hasRunning);
3084
+ };
3085
+
3086
+ visible.filter(s => !_groups.has(s.source)).forEach(s => {
3087
+ while (pending.length && shouldFlush(pending[0], s)) renderGroup(pending.shift());
3033
3088
  _renderSessionItem(list, s);
3034
3089
  });
3035
- // Only append at the bottom when all pages are exhausted; otherwise
3036
- // the entry would show prematurely on the first page even though
3037
- // its time position is several pages deeper.
3038
- if (!cronEntryRendered && !_hasMore) {
3039
- _renderCronGroupItem(list, _cronCount, cronHasRunning);
3040
- }
3041
- } else {
3042
- // Normal list view, no cron sessions
3043
- visible.forEach(s => _renderSessionItem(list, s));
3090
+ // Only append leftovers at the bottom when all pages are exhausted;
3091
+ // otherwise an entry would show prematurely on the first page even
3092
+ // though its time position is several pages deeper.
3093
+ if (!_hasMore) pending.forEach(renderGroup);
3044
3094
  }
3045
3095
  if (list.children.length === 0) {
3046
3096
  list.innerHTML = `<div class="session-empty">${I18n.t("sessions.empty")}</div>`;
3047
3097
  }
3048
3098
 
3049
- if (isCronView) {
3050
- if (_cronHasMore) list.appendChild(_makeLoadMoreBtn(true));
3099
+ if (_groupView) {
3100
+ if (_groups.get(_groupView).hasMore) list.appendChild(_makeLoadMoreBtn(_groupView));
3051
3101
  } else if (_hasMore) {
3052
3102
  list.appendChild(_makeLoadMoreBtn());
3053
3103
  }
@@ -4190,8 +4240,8 @@ const Sessions = (() => {
4190
4240
  const sid = _activeId;
4191
4241
  const todos = (sid && Sessions._sessionTodos[sid]) || [];
4192
4242
 
4193
- // No active session or no todos → hide.
4194
- if (!sid || todos.length === 0) {
4243
+ // No active session, no todos, or single todo → hide.
4244
+ if (!sid || todos.length <= 1) {
4195
4245
  panel.style.display = "none";
4196
4246
  panel.innerHTML = "";
4197
4247
  return;
@@ -1403,6 +1403,7 @@ const Settings = (() => {
1403
1403
  const res = await fetch("/api/config/search");
1404
1404
  const data = await res.json();
1405
1405
  if (data.ok) {
1406
+ while (select.options.length > 1) select.remove(1);
1406
1407
  (data.available || []).forEach((name) => {
1407
1408
  const opt = document.createElement("option");
1408
1409
  opt.value = name;