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.
- checksums.yaml +4 -4
- data/.clacky/skills/gem-release/SKILL.md +18 -1
- data/.clackyrules +25 -1
- data/CHANGELOG.md +26 -0
- data/CONTRIBUTING.md +9 -0
- data/Rakefile +15 -0
- data/docs/design-emit-and-background-skills.md +253 -0
- data/docs/mcp-architecture.md +48 -9
- data/docs/mcp.example.json +9 -0
- data/docs/superpowers/plans/2026-08-09-chatcut-extension-bridge.md +191 -0
- data/docs/superpowers/plans/2026-08-09-native-mcp-oauth.md +243 -0
- data/docs/superpowers/specs/2026-08-09-mcp-oauth-extension-fallback-design.md +115 -0
- data/lib/clacky/agent/cost_tracker.rb +17 -2
- data/lib/clacky/agent/hook_manager.rb +14 -3
- data/lib/clacky/agent/memory_updater.rb +17 -18
- data/lib/clacky/agent/message_compressor_helper.rb +23 -0
- data/lib/clacky/agent/session_serializer.rb +136 -22
- data/lib/clacky/agent/skill_auto_creator.rb +1 -2
- data/lib/clacky/agent/skill_evolution.rb +11 -7
- data/lib/clacky/agent/skill_manager.rb +20 -18
- data/lib/clacky/agent/skill_reflector.rb +3 -6
- data/lib/clacky/agent.rb +111 -20
- data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
- data/lib/clacky/cli.rb +4 -0
- data/lib/clacky/client.rb +1 -1
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +13 -5
- data/lib/clacky/extension/api_extension.rb +11 -3
- data/lib/clacky/fanout.rb +100 -0
- data/lib/clacky/locales/en.rb +2 -0
- data/lib/clacky/locales/zh.rb +2 -0
- data/lib/clacky/mcp/cli_commands.rb +61 -0
- data/lib/clacky/mcp/client.rb +18 -2
- data/lib/clacky/mcp/connection_manager.rb +85 -0
- data/lib/clacky/mcp/http_transport.rb +25 -9
- data/lib/clacky/mcp/oauth/authorization_manager.rb +257 -0
- data/lib/clacky/mcp/oauth/config.rb +51 -0
- data/lib/clacky/mcp/oauth/credential_store.rb +82 -0
- data/lib/clacky/mcp/oauth/session.rb +88 -0
- data/lib/clacky/message_format/anthropic.rb +27 -13
- data/lib/clacky/message_history.rb +32 -1
- data/lib/clacky/providers.rb +11 -5
- data/lib/clacky/server/http_server.rb +42 -22
- data/lib/clacky/server/session_registry.rb +11 -15
- data/lib/clacky/server/web_ui_controller.rb +1 -1
- data/lib/clacky/session_manager.rb +13 -11
- data/lib/clacky/shell_hook_loader.rb +14 -8
- data/lib/clacky/tools/invoke_skill.rb +3 -2
- data/lib/clacky/tools/terminal.rb +11 -4
- data/lib/clacky/ui2/progress_handle.rb +27 -3
- data/lib/clacky/ui2/ui_controller.rb +206 -6
- data/lib/clacky/ui_interface.rb +13 -3
- data/lib/clacky/utils/arguments_parser.rb +73 -4
- data/lib/clacky/utils/model_pricing.rb +21 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +10 -3
- data/lib/clacky/web/features/new-session/view.js +19 -2
- data/lib/clacky/web/i18n.js +22 -8
- data/lib/clacky/web/index.html +7 -11
- data/lib/clacky/web/projects.js +26 -0
- data/lib/clacky/web/sessions.js +169 -119
- data/lib/clacky/web/settings.js +1 -0
- data/lib/clacky/web/ws-dispatcher.js +61 -34
- data/lib/clacky.rb +2 -0
- metadata +12 -1
data/lib/clacky/web/index.html
CHANGED
|
@@ -156,14 +156,14 @@
|
|
|
156
156
|
</div>
|
|
157
157
|
</div>
|
|
158
158
|
|
|
159
|
-
<!--
|
|
160
|
-
<div id="
|
|
161
|
-
<button id="btn-
|
|
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-
|
|
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-
|
|
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>
|
data/lib/clacky/web/projects.js
CHANGED
|
@@ -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);
|
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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(
|
|
2171
|
+
function _makeLoadMoreBtn(groupSource = null) {
|
|
2130
2172
|
const btn = document.createElement("button");
|
|
2131
2173
|
btn.className = "btn-load-more-sessions";
|
|
2132
|
-
const loading =
|
|
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 = () =>
|
|
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
|
|
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
|
-
// ──
|
|
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
|
|
2291
|
-
// and render no dot at all when the group is idle.
|
|
2292
|
-
function
|
|
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 =
|
|
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"
|
|
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.
|
|
2341
|
+
<div class="session-meta">${I18n.t("sessions.groupMeta", { n: count })}</div>
|
|
2303
2342
|
</div>
|
|
2304
2343
|
`;
|
|
2305
2344
|
el.onclick = () => {
|
|
2306
|
-
|
|
2345
|
+
_groupView = source;
|
|
2307
2346
|
Sessions.renderList();
|
|
2308
|
-
//
|
|
2309
|
-
//
|
|
2310
|
-
|
|
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(
|
|
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
|
|
2360
|
+
const groupHeader = document.getElementById("group-view-header");
|
|
2323
2361
|
|
|
2324
|
-
if (
|
|
2362
|
+
if (groupSource) {
|
|
2325
2363
|
if (normalHeader) normalHeader.style.display = "none";
|
|
2326
|
-
if (
|
|
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 (
|
|
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
|
-
//
|
|
2404
|
-
document.getElementById("btn-
|
|
2446
|
+
// Group sub-view back button
|
|
2447
|
+
document.getElementById("btn-group-back")
|
|
2405
2448
|
.addEventListener("click", () => {
|
|
2406
|
-
|
|
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,
|
|
2499
|
+
setAll(list, hasMore = false, groups = {}) {
|
|
2457
2500
|
_sessions.length = 0;
|
|
2458
2501
|
_sessions.push(...list);
|
|
2459
|
-
_hasMore
|
|
2460
|
-
|
|
2461
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2517
|
-
const updated =
|
|
2518
|
-
|
|
2519
|
-
if (
|
|
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 && (!
|
|
2522
|
-
|
|
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
|
-
|
|
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-
|
|
2575
|
-
//
|
|
2576
|
-
// their own independent cursor
|
|
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
|
|
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: "
|
|
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
|
|
2598
|
-
|
|
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
|
-
/**
|
|
2611
|
-
* list's `_hasMore` / `loadMore` cursor. Fetches the next page of
|
|
2612
|
-
* sessions (type
|
|
2613
|
-
* (deduped), so WS patch/remove/add keep working
|
|
2614
|
-
* sub-view's own cursor + flags advance. Pass
|
|
2615
|
-
* from the newest
|
|
2616
|
-
async
|
|
2617
|
-
|
|
2618
|
-
if (!
|
|
2619
|
-
|
|
2620
|
-
|
|
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:
|
|
2628
|
-
if (
|
|
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
|
|
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)
|
|
2648
|
-
|
|
2649
|
-
|
|
2700
|
+
if (oldest) group.before = oldest;
|
|
2701
|
+
group.hasMore = !!data.has_more;
|
|
2702
|
+
_applyGroupStats(data.groups);
|
|
2650
2703
|
} catch (e) {
|
|
2651
|
-
console.error("
|
|
2704
|
+
console.error("loadMoreGroup error:", e);
|
|
2652
2705
|
} finally {
|
|
2653
|
-
|
|
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
|
-
// ──
|
|
2999
|
-
|
|
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 (
|
|
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
|
-
|
|
3013
|
-
|
|
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
|
-
|
|
3017
|
-
} else
|
|
3018
|
-
//
|
|
3019
|
-
//
|
|
3020
|
-
//
|
|
3021
|
-
const
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
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;
|
|
3036
|
-
//
|
|
3037
|
-
// its time position is several pages deeper.
|
|
3038
|
-
if (!
|
|
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 (
|
|
3050
|
-
if (
|
|
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
|
|
4194
|
-
if (!sid || todos.length
|
|
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;
|
data/lib/clacky/web/settings.js
CHANGED
|
@@ -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;
|