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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/lib/clacky/agent/message_compressor.rb +32 -8
- data/lib/clacky/agent/message_compressor_helper.rb +113 -12
- data/lib/clacky/agent.rb +8 -4
- data/lib/clacky/agent_profile.rb +121 -53
- data/lib/clacky/brand_config.rb +309 -6
- data/lib/clacky/cli.rb +4 -58
- data/lib/clacky/default_extensions/coding/ext.yml +18 -0
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +95 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +254 -0
- data/lib/clacky/default_extensions/ext-studio/ext.yml +32 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +1269 -0
- data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +71 -0
- data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +74 -0
- data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +65 -0
- data/lib/clacky/default_extensions/general/ext.yml +18 -0
- data/lib/clacky/default_extensions/git/ext.yml +12 -0
- data/lib/clacky/{default_agents/_panels/git/panel.js → default_extensions/git/panels/git/view.js} +1 -2
- data/lib/clacky/default_extensions/meeting/{handler.rb → api/handler.rb} +78 -26
- data/lib/clacky/default_extensions/meeting/ext.yml +15 -0
- data/lib/clacky/default_extensions/meeting/{meeting.js → panels/meeting/view.js} +266 -89
- data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +2 -1
- data/lib/clacky/default_extensions/time_machine/ext.yml +12 -0
- data/lib/clacky/{default_agents/_panels/time_machine/panel.js → default_extensions/time_machine/panels/time_machine/view.js} +58 -47
- data/lib/clacky/default_skills/channel-manager/discord_setup.rb +1 -1
- data/lib/clacky/default_skills/extend-openclacky/SKILL.md +83 -18
- data/lib/clacky/{api_extension.rb → extension/api_extension.rb} +27 -11
- data/lib/clacky/extension/api_loader.rb +136 -0
- data/lib/clacky/extension/cli_commands.rb +226 -0
- data/lib/clacky/{server/api_extension_dispatcher.rb → extension/dispatcher.rb} +22 -9
- data/lib/clacky/extension/hook_loader.rb +77 -0
- data/lib/clacky/extension/loader.rb +483 -0
- data/lib/clacky/extension/packager.rb +226 -0
- data/lib/clacky/{patch_loader.rb → extension/patch_loader.rb} +45 -0
- data/lib/clacky/extension/scaffold/template_renderer.rb +64 -0
- data/lib/clacky/extension/scaffold/templates/full/README.md.erb +48 -0
- data/lib/clacky/extension/scaffold/templates/full/agents/designer.md +5 -0
- data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
- data/lib/clacky/extension/scaffold/templates/full/channels/noop.rb.erb +40 -0
- data/lib/clacky/extension/scaffold/templates/full/ext.yml.erb +41 -0
- data/lib/clacky/extension/scaffold/templates/full/hooks/audit.rb +9 -0
- data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +194 -0
- data/lib/clacky/extension/scaffold/templates/full/patches/audit.rb +15 -0
- data/lib/clacky/extension/scaffold/templates/full/skills/__slug__-skill/SKILL.md.erb +8 -0
- data/lib/clacky/extension/scaffold/templates/hello/api/handler.rb.erb +9 -0
- data/lib/clacky/extension/scaffold/templates/hello/ext.yml.erb +12 -0
- data/lib/clacky/extension/scaffold/templates/hello/panels/hello/view.js.erb +31 -0
- data/lib/clacky/extension/scaffold.rb +55 -0
- data/lib/clacky/extension/verifier.rb +196 -0
- data/lib/clacky/identity.rb +70 -0
- data/lib/clacky/locales/en.rb +19 -1
- data/lib/clacky/locales/zh.rb +19 -1
- data/lib/clacky/platform_http_client.rb +18 -15
- data/lib/clacky/server/channel/extension_adapter_loader.rb +32 -0
- data/lib/clacky/server/channel.rb +6 -0
- data/lib/clacky/server/http_server.rb +435 -263
- data/lib/clacky/server/session_registry.rb +59 -3
- data/lib/clacky/session_manager.rb +35 -4
- data/lib/clacky/skill_loader.rb +28 -1
- data/lib/clacky/ui2/layout_manager.rb +0 -5
- data/lib/clacky/ui2/progress_handle.rb +0 -3
- data/lib/clacky/ui2/ui_controller.rb +9 -15
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +782 -554
- data/lib/clacky/web/app.js +63 -15
- data/lib/clacky/web/auth.js +2 -0
- data/lib/clacky/web/components/notify.js +2 -0
- data/lib/clacky/web/components/sidebar.js +2 -8
- data/lib/clacky/web/core/ext.js +316 -79
- data/lib/clacky/web/features/backup/store.js +24 -0
- data/lib/clacky/web/features/backup/view.js +49 -23
- data/lib/clacky/web/features/brand/view.js +13 -5
- data/lib/clacky/web/features/extensions/store.js +184 -0
- data/lib/clacky/web/features/extensions/view.js +389 -0
- data/lib/clacky/web/features/new-session/store.js +186 -0
- data/lib/clacky/web/features/new-session/view.js +319 -0
- data/lib/clacky/web/features/share/view.js +63 -14
- data/lib/clacky/web/features/skills/store.js +3 -1
- data/lib/clacky/web/features/skills/view.js +1 -1
- data/lib/clacky/web/features/tasks/view.js +88 -27
- data/lib/clacky/web/features/trash/store.js +24 -6
- data/lib/clacky/web/features/trash/view.js +46 -9
- data/lib/clacky/web/features/workspace/store.js +2 -0
- data/lib/clacky/web/features/workspace/view.js +0 -1
- data/lib/clacky/web/i18n.js +140 -108
- data/lib/clacky/web/index.html +190 -214
- data/lib/clacky/web/sessions.js +99 -304
- data/lib/clacky/web/settings.js +1 -1
- data/lib/clacky/web/skills.js +2 -0
- data/lib/clacky/web/theme.js +2 -0
- data/lib/clacky/web/ws-dispatcher.js +2 -1
- data/lib/clacky/web/ws.js +2 -0
- data/lib/clacky.rb +21 -10
- metadata +55 -24
- data/benchmark/runner.rb +0 -441
- data/lib/clacky/api_extension_loader.rb +0 -168
- data/lib/clacky/default_agents/SOUL.md +0 -3
- data/lib/clacky/default_agents/USER.md +0 -1
- data/lib/clacky/default_agents/coding/profile.yml +0 -5
- data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
- data/lib/clacky/default_agents/general/profile.yml +0 -2
- data/lib/clacky/default_extensions/meeting/meta.yml +0 -3
- data/lib/clacky/web/features/creator/store.js +0 -81
- data/lib/clacky/web/features/creator/view.js +0 -380
- /data/lib/clacky/{default_agents → default_extensions/coding/agents}/coding/system_prompt.md +0 -0
- /data/lib/clacky/{default_agents → default_extensions/general/agents}/general/system_prompt.md +0 -0
- /data/lib/clacky/{default_agents/base_prompt.md → prompts/base.md} +0 -0
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -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", () =>
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
2169
|
-
//
|
|
2170
|
-
|
|
2171
|
-
|
|
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}${
|
|
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
|
|
2385
|
-
_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
|
|
2464
|
-
//
|
|
2465
|
-
//
|
|
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;
|
|
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
|
|
2491
|
-
_cronCount
|
|
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:
|
|
2888
|
-
//
|
|
2889
|
-
//
|
|
2890
|
-
|
|
2891
|
-
|
|
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
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
if (!
|
|
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
|
-
//
|
|
2910
|
-
//
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
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
|
-
|
|
2941
|
-
|
|
2942
|
-
//
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
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 (
|
|
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;
|
data/lib/clacky/web/settings.js
CHANGED
data/lib/clacky/web/skills.js
CHANGED
data/lib/clacky/web/theme.js
CHANGED
|
@@ -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();
|