openclacky 1.2.17 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/lib/clacky/agent/skill_manager.rb +1 -1
- data/lib/clacky/agent/time_machine.rb +256 -74
- data/lib/clacky/agent/tool_executor.rb +12 -0
- data/lib/clacky/agent.rb +21 -31
- data/lib/clacky/agent_config.rb +18 -0
- data/lib/clacky/cli.rb +55 -3
- data/lib/clacky/default_skills/media-gen/SKILL.md +173 -5
- data/lib/clacky/default_skills/skill-creator/SKILL.md +1 -0
- data/lib/clacky/media/base.rb +125 -0
- data/lib/clacky/media/dashscope.rb +243 -0
- data/lib/clacky/media/gemini.rb +10 -0
- data/lib/clacky/media/generator.rb +75 -0
- data/lib/clacky/media/openai_compat.rb +160 -0
- data/lib/clacky/message_history.rb +12 -7
- data/lib/clacky/providers.rb +28 -0
- data/lib/clacky/rich_ui_controller.rb +3 -1
- data/lib/clacky/server/backup_manager.rb +200 -0
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -2
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +68 -15
- data/lib/clacky/server/channel/channel_manager.rb +180 -81
- data/lib/clacky/server/http_server.rb +348 -15
- data/lib/clacky/server/scheduler.rb +19 -0
- data/lib/clacky/server/session_registry.rb +8 -4
- data/lib/clacky/session_manager.rb +40 -2
- data/lib/clacky/skill.rb +3 -1
- data/lib/clacky/tools/trash_manager.rb +14 -0
- data/lib/clacky/ui2/components/command_suggestions.rb +1 -0
- data/lib/clacky/ui2/components/modal_component.rb +34 -7
- data/lib/clacky/ui2/ui_controller.rb +150 -19
- data/lib/clacky/utils/file_processor.rb +75 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +2038 -1147
- data/lib/clacky/web/app.js +22 -1
- data/lib/clacky/web/backup.js +119 -0
- data/lib/clacky/web/billing.js +94 -7
- data/lib/clacky/web/channels.js +81 -11
- data/lib/clacky/web/design-sample.css +247 -0
- data/lib/clacky/web/design-sample.html +127 -0
- data/lib/clacky/web/favicon.svg +16 -0
- data/lib/clacky/web/i18n.js +159 -31
- data/lib/clacky/web/index.html +175 -55
- data/lib/clacky/web/logo_nav_dark.png +0 -0
- data/lib/clacky/web/onboard.js +114 -28
- data/lib/clacky/web/sessions.js +436 -192
- data/lib/clacky/web/settings.js +21 -1
- data/lib/clacky/web/skills.js +6 -6
- data/lib/clacky/web/tasks.js +129 -61
- data/lib/clacky/web/utils.js +72 -0
- data/lib/clacky/web/ws-dispatcher.js +6 -0
- data/lib/clacky.rb +1 -0
- metadata +8 -3
- data/lib/clacky/server/channel/group_message_buffer.rb +0 -53
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -24,8 +24,11 @@ const Sessions = (() => {
|
|
|
24
24
|
let _hasMore = false; // unified pagination: are there older sessions to load?
|
|
25
25
|
let _loadingMore = false;
|
|
26
26
|
// Search state
|
|
27
|
-
const _filter = { q: "", date: "", type: "" }; // committed filter (applied to
|
|
28
|
-
let _searchOpen = false; // is the search
|
|
27
|
+
const _filter = { q: "", date: "", type: "" }; // committed filter (applied to the search overlay)
|
|
28
|
+
let _searchOpen = false; // is the command-palette search overlay visible?
|
|
29
|
+
// Search results live in their own list, rendered into the overlay's
|
|
30
|
+
// #session-search-results — they NEVER replace the sidebar session list.
|
|
31
|
+
let _searchResults = [];
|
|
29
32
|
// Active search result split when _filter.q is non-empty:
|
|
30
33
|
// { nameIds: Set<id>, contentIds: Set<id>, contentLoaded: bool }
|
|
31
34
|
let _searchSplit = null;
|
|
@@ -460,6 +463,20 @@ const Sessions = (() => {
|
|
|
460
463
|
document.getElementById("btn-welcome-new")
|
|
461
464
|
.addEventListener("click", () => Sessions.create("general"));
|
|
462
465
|
|
|
466
|
+
// Welcome screen starter chips: create a session, then prefill the prompt
|
|
467
|
+
document.querySelectorAll(".chip[data-welcome-prompt]").forEach((chip) => {
|
|
468
|
+
chip.addEventListener("click", async () => {
|
|
469
|
+
await Sessions.create("general");
|
|
470
|
+
const prompt = I18n.t(chip.dataset.welcomePrompt);
|
|
471
|
+
const input = document.getElementById("user-input");
|
|
472
|
+
if (input && prompt) {
|
|
473
|
+
input.value = prompt;
|
|
474
|
+
input.focus();
|
|
475
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
463
480
|
// Modal: cancel / create / overlay click
|
|
464
481
|
document.getElementById("new-session-cancel")
|
|
465
482
|
.addEventListener("click", () => Sessions.closeNewSessionModal());
|
|
@@ -474,10 +491,17 @@ const Sessions = (() => {
|
|
|
474
491
|
}
|
|
475
492
|
});
|
|
476
493
|
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
|
|
494
|
+
// Working-directory browse button → reuses the session picker in
|
|
495
|
+
// session-less mode (no session exists yet, so it browses via /api/dirs).
|
|
496
|
+
const browseBtn = document.getElementById("new-session-browse-btn");
|
|
497
|
+
if (browseBtn) {
|
|
498
|
+
browseBtn.addEventListener("click", async () => {
|
|
499
|
+
const dirInput = document.getElementById("new-session-directory");
|
|
500
|
+
const start = dirInput ? dirInput.value.trim() : "";
|
|
501
|
+
const picked = await window.openDirectoryPicker(start, null);
|
|
502
|
+
if (picked && dirInput) dirInput.value = picked;
|
|
503
|
+
});
|
|
504
|
+
}
|
|
481
505
|
|
|
482
506
|
// Load-more sessions button is rendered dynamically by renderList(),
|
|
483
507
|
// so we listen via event delegation.
|
|
@@ -866,21 +890,39 @@ const Sessions = (() => {
|
|
|
866
890
|
// Everything uses event delegation because some elements (e.g. the clear
|
|
867
891
|
// buttons) are re-rendered as filter state changes.
|
|
868
892
|
function _initSearch() {
|
|
869
|
-
//
|
|
893
|
+
// Open the palette: top cmdbar button (or ⌘K, bound below).
|
|
870
894
|
document.addEventListener("click", (e) => {
|
|
871
|
-
if (e.target && e.target.closest("#
|
|
872
|
-
Sessions.toggleSearch();
|
|
895
|
+
if (e.target && e.target.closest("#header-cmdbar")) {
|
|
896
|
+
if (!Sessions.searchOpen) Sessions.toggleSearch();
|
|
873
897
|
}
|
|
874
898
|
});
|
|
875
899
|
|
|
876
|
-
// Close button inside
|
|
900
|
+
// Close button inside palette.
|
|
877
901
|
document.addEventListener("click", (e) => {
|
|
878
|
-
if (e.target && e.target.
|
|
902
|
+
if (e.target && e.target.closest("#btn-session-search-close")) {
|
|
879
903
|
if (Sessions.searchOpen) Sessions.toggleSearch();
|
|
880
904
|
}
|
|
881
905
|
});
|
|
882
906
|
|
|
883
|
-
//
|
|
907
|
+
// Click on the dimmed backdrop (outside the palette card) closes it.
|
|
908
|
+
document.addEventListener("click", (e) => {
|
|
909
|
+
if (e.target && e.target.id === "session-search-overlay" && Sessions.searchOpen) {
|
|
910
|
+
Sessions.toggleSearch();
|
|
911
|
+
}
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
// ⌘K / Ctrl-K toggles the palette; Esc closes it.
|
|
915
|
+
document.addEventListener("keydown", (e) => {
|
|
916
|
+
if ((e.metaKey || e.ctrlKey) && (e.key === "k" || e.key === "K")) {
|
|
917
|
+
e.preventDefault();
|
|
918
|
+
Sessions.toggleSearch();
|
|
919
|
+
} else if (e.key === "Escape" && Sessions.searchOpen) {
|
|
920
|
+
e.preventDefault();
|
|
921
|
+
Sessions.toggleSearch();
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
// Enter key → commit search immediately.
|
|
884
926
|
// Bound on the input directly so IME.bindEnter can attach compositionend
|
|
885
927
|
// to the input itself (Safari needs the timestamp to suppress fake Enters).
|
|
886
928
|
const searchInput = document.getElementById("session-search-q");
|
|
@@ -908,11 +950,14 @@ const Sessions = (() => {
|
|
|
908
950
|
}
|
|
909
951
|
});
|
|
910
952
|
|
|
911
|
-
// Show/hide inline ✕ as user types
|
|
953
|
+
// Show/hide inline ✕ + debounced live search as the user types.
|
|
954
|
+
let _searchDebounce = null;
|
|
912
955
|
document.addEventListener("input", (e) => {
|
|
913
956
|
if (e.target && e.target.id === "session-search-q") {
|
|
914
957
|
const btn = document.getElementById("btn-search-q-clear");
|
|
915
958
|
if (btn) btn.hidden = !e.target.value;
|
|
959
|
+
clearTimeout(_searchDebounce);
|
|
960
|
+
_searchDebounce = setTimeout(() => Sessions.commitSearch(), 200);
|
|
916
961
|
}
|
|
917
962
|
});
|
|
918
963
|
|
|
@@ -1080,18 +1125,26 @@ const Sessions = (() => {
|
|
|
1080
1125
|
function _toggleToolItemDetails(item) {
|
|
1081
1126
|
if (!item) return;
|
|
1082
1127
|
const details = item.querySelector(".tool-item-details");
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1128
|
+
const stdout = item.querySelector(".tool-item-stdout");
|
|
1129
|
+
// Determine current expanded state: either details or stdout is visible
|
|
1130
|
+
const detailsVisible = details && details.style.display !== "none";
|
|
1131
|
+
const stdoutVisible = stdout && stdout.style.display !== "none";
|
|
1132
|
+
const isExpanded = detailsVisible || stdoutVisible;
|
|
1133
|
+
|
|
1134
|
+
if (!isExpanded) {
|
|
1135
|
+
if (details) {
|
|
1136
|
+
if (!details.dataset.filled) {
|
|
1137
|
+
const json = item.dataset.argsJson || "";
|
|
1138
|
+
details.textContent = json;
|
|
1139
|
+
details.dataset.filled = "1";
|
|
1140
|
+
}
|
|
1141
|
+
if (item.dataset.argsJson) details.style.display = "";
|
|
1142
|
+
}
|
|
1143
|
+
if (stdout && stdout.innerHTML.trim()) stdout.style.display = "";
|
|
1092
1144
|
item.classList.add("expanded");
|
|
1093
1145
|
} else {
|
|
1094
|
-
details.style.display = "none";
|
|
1146
|
+
if (details) details.style.display = "none";
|
|
1147
|
+
if (stdout) stdout.style.display = "none";
|
|
1095
1148
|
item.classList.remove("expanded");
|
|
1096
1149
|
}
|
|
1097
1150
|
}
|
|
@@ -1192,7 +1245,8 @@ const Sessions = (() => {
|
|
|
1192
1245
|
}
|
|
1193
1246
|
|
|
1194
1247
|
// Mark the last tool-item in a group as done (update status indicator).
|
|
1195
|
-
|
|
1248
|
+
// collapsed: true → keep stdout hidden (history mode); false → show immediately (live mode).
|
|
1249
|
+
function _completeLastToolItem(group, result, { collapsed = false } = {}) {
|
|
1196
1250
|
const body = group.querySelector(".tool-group-body");
|
|
1197
1251
|
const items = body.querySelectorAll(".tool-item");
|
|
1198
1252
|
if (!items.length) return;
|
|
@@ -1208,20 +1262,25 @@ const Sessions = (() => {
|
|
|
1208
1262
|
try { parsedArgs = JSON.parse(last.dataset.argsJson || "null"); } catch (_) {}
|
|
1209
1263
|
if (parsedArgs) _renderEditWriteDiff(last, toolName, parsedArgs);
|
|
1210
1264
|
}
|
|
1211
|
-
// Render the result string (e.g. "waiting (#4) — 128B\nstep1\nstep2…")
|
|
1212
|
-
// into the stdout area so the user can see what actually happened.
|
|
1213
|
-
// If the area already has streamed content (future feature), leave it.
|
|
1214
1265
|
const stdout = last.querySelector(".tool-item-stdout");
|
|
1215
1266
|
if (stdout) {
|
|
1216
1267
|
const existing = stdout.textContent.trim();
|
|
1217
1268
|
const resultStr = (result == null) ? "" : String(result).trim();
|
|
1218
1269
|
if (!existing && resultStr) {
|
|
1219
1270
|
stdout.innerHTML = _ansiToHtml(resultStr);
|
|
1220
|
-
|
|
1221
|
-
|
|
1271
|
+
}
|
|
1272
|
+
const hasContent = !!stdout.textContent.trim();
|
|
1273
|
+
if (hasContent) {
|
|
1274
|
+
// Collapse stdout once the command finishes; header click re-expands.
|
|
1275
|
+
stdout.style.display = "none";
|
|
1276
|
+
last.classList.remove("expanded");
|
|
1277
|
+
const header = last.querySelector(".tool-item-header");
|
|
1278
|
+
if (header && !header.classList.contains("tool-item-expandable")) {
|
|
1279
|
+
header.classList.add("tool-item-expandable");
|
|
1280
|
+
}
|
|
1281
|
+
} else {
|
|
1222
1282
|
stdout.style.display = "none";
|
|
1223
1283
|
}
|
|
1224
|
-
// else: leave existing content as-is
|
|
1225
1284
|
}
|
|
1226
1285
|
}
|
|
1227
1286
|
|
|
@@ -1336,7 +1395,11 @@ const Sessions = (() => {
|
|
|
1336
1395
|
const resultStr = (ev.result == null) ? "" : String(ev.result).trim();
|
|
1337
1396
|
if (resultStr && !stdout.textContent.trim()) {
|
|
1338
1397
|
stdout.innerHTML = _ansiToHtml(resultStr);
|
|
1339
|
-
|
|
1398
|
+
// Collapsed by default in history; click header to expand
|
|
1399
|
+
const header = historyCtx.lastItem.querySelector(".tool-item-header");
|
|
1400
|
+
if (header && !header.classList.contains("tool-item-expandable")) {
|
|
1401
|
+
header.classList.add("tool-item-expandable");
|
|
1402
|
+
}
|
|
1340
1403
|
} else if (!resultStr && !stdout.textContent.trim()) {
|
|
1341
1404
|
stdout.style.display = "none";
|
|
1342
1405
|
}
|
|
@@ -1419,6 +1482,10 @@ const Sessions = (() => {
|
|
|
1419
1482
|
if (!stdout) return;
|
|
1420
1483
|
stdout.innerHTML += lines.map(_ansiToHtml).join("");
|
|
1421
1484
|
if (stdout.style.display === "none") stdout.style.display = "";
|
|
1485
|
+
const header = toolItem.querySelector(".tool-item-header");
|
|
1486
|
+
if (header && !header.classList.contains("tool-item-expandable")) {
|
|
1487
|
+
header.classList.add("tool-item-expandable");
|
|
1488
|
+
}
|
|
1422
1489
|
stdout.scrollTop = stdout.scrollHeight;
|
|
1423
1490
|
const messages = RenderTarget.outer();
|
|
1424
1491
|
_scrollToBottomIfNeeded(messages);
|
|
@@ -1823,7 +1890,7 @@ const Sessions = (() => {
|
|
|
1823
1890
|
metaParts.push(I18n.t("sessions.metaTasks", { n: s.total_tasks }));
|
|
1824
1891
|
}
|
|
1825
1892
|
metaParts.push(_relativeTime(s.updated_at || s.created_at));
|
|
1826
|
-
const metaText = metaParts.join(
|
|
1893
|
+
const metaText = metaParts.join('<span class="session-meta-sep"></span>');
|
|
1827
1894
|
|
|
1828
1895
|
// Source badge — primary identity (cron/channel/setup).
|
|
1829
1896
|
// Coding is the agent_profile (what kind of assistant is inside); we
|
|
@@ -1935,19 +2002,13 @@ const Sessions = (() => {
|
|
|
1935
2002
|
|
|
1936
2003
|
const normalHeader = chatSection.querySelector(":scope > .sidebar-divider:first-of-type");
|
|
1937
2004
|
const cronHeader = document.getElementById("cron-view-header");
|
|
1938
|
-
const searchBar = document.getElementById("session-search-bar");
|
|
1939
|
-
const newSessionBtn = document.getElementById("btn-session-search-toggle");
|
|
1940
2005
|
|
|
1941
2006
|
if (isCronView) {
|
|
1942
2007
|
if (normalHeader) normalHeader.style.display = "none";
|
|
1943
2008
|
if (cronHeader) cronHeader.style.display = "";
|
|
1944
|
-
if (searchBar) searchBar.hidden = true;
|
|
1945
|
-
if (newSessionBtn) newSessionBtn.style.display = "none";
|
|
1946
2009
|
} else {
|
|
1947
2010
|
if (normalHeader) normalHeader.style.display = "";
|
|
1948
2011
|
if (cronHeader) cronHeader.style.display = "none";
|
|
1949
|
-
if (searchBar) searchBar.hidden = !_searchOpen;
|
|
1950
|
-
// newSessionBtn display managed by renderList's magnifier logic
|
|
1951
2012
|
}
|
|
1952
2013
|
}
|
|
1953
2014
|
|
|
@@ -2068,23 +2129,26 @@ const Sessions = (() => {
|
|
|
2068
2129
|
if (_loadingMore || !_hasMore) return;
|
|
2069
2130
|
_loadingMore = true;
|
|
2070
2131
|
|
|
2071
|
-
// Save scroll position so the sidebar
|
|
2072
|
-
//
|
|
2132
|
+
// Save scroll position so the sidebar stays put across the DOM rebuild
|
|
2133
|
+
// that renderList() performs (clearing + repopulating the list can reset
|
|
2134
|
+
// the container's scrollTop).
|
|
2073
2135
|
const sidebarList = document.getElementById("sidebar-list");
|
|
2074
2136
|
const savedScrollTop = sidebarList ? sidebarList.scrollTop : 0;
|
|
2075
|
-
Sessions.renderList(
|
|
2137
|
+
Sessions.renderList();
|
|
2076
2138
|
|
|
2077
2139
|
try {
|
|
2078
|
-
// Cursor: oldest
|
|
2079
|
-
//
|
|
2080
|
-
//
|
|
2081
|
-
//
|
|
2082
|
-
// cause the cursor to jump too far back and
|
|
2083
|
-
// the oldest pinned one and the real
|
|
2140
|
+
// Cursor: oldest activity time (updated_at, falling back to
|
|
2141
|
+
// created_at) in the current list, EXCLUDING pinned sessions. The
|
|
2142
|
+
// backend always returns ALL pinned sessions on the first page (they
|
|
2143
|
+
// bypass pagination), so their time is irrelevant for the cursor.
|
|
2144
|
+
// Including them here would cause the cursor to jump too far back and
|
|
2145
|
+
// skip sessions between the oldest pinned one and the real
|
|
2146
|
+
// last-loaded non-pinned row.
|
|
2084
2147
|
const oldest = _sessions.reduce((min, s) => {
|
|
2085
2148
|
if (s.pinned) return min; // ignore pinned
|
|
2086
|
-
|
|
2087
|
-
|
|
2149
|
+
const t = s.updated_at || s.created_at;
|
|
2150
|
+
if (!t) return min;
|
|
2151
|
+
return (!min || t < min) ? t : min;
|
|
2088
2152
|
}, null);
|
|
2089
2153
|
|
|
2090
2154
|
const params = new URLSearchParams({ limit: "20" });
|
|
@@ -2106,7 +2170,7 @@ const Sessions = (() => {
|
|
|
2106
2170
|
console.error("loadMore error:", e);
|
|
2107
2171
|
} finally {
|
|
2108
2172
|
_loadingMore = false;
|
|
2109
|
-
Sessions.renderList(
|
|
2173
|
+
Sessions.renderList();
|
|
2110
2174
|
// Restore scroll position so the user stays where they were
|
|
2111
2175
|
if (sidebarList) sidebarList.scrollTop = savedScrollTop;
|
|
2112
2176
|
}
|
|
@@ -2126,7 +2190,7 @@ const Sessions = (() => {
|
|
|
2126
2190
|
|
|
2127
2191
|
const sidebarList = document.getElementById("sidebar-list");
|
|
2128
2192
|
const savedScrollTop = sidebarList ? sidebarList.scrollTop : 0;
|
|
2129
|
-
Sessions.renderList(
|
|
2193
|
+
Sessions.renderList();
|
|
2130
2194
|
|
|
2131
2195
|
try {
|
|
2132
2196
|
const params = new URLSearchParams({ limit: "20", type: "cron" });
|
|
@@ -2140,12 +2204,14 @@ const Sessions = (() => {
|
|
|
2140
2204
|
rows.forEach(s => {
|
|
2141
2205
|
if (!_sessions.find(x => x.id === s.id)) _sessions.push(s);
|
|
2142
2206
|
});
|
|
2143
|
-
// Advance cursor to the oldest cron
|
|
2144
|
-
// pinned — backend returns all pinned on the first page,
|
|
2145
|
-
// pagination, so their
|
|
2207
|
+
// Advance cursor to the oldest cron activity time in THIS batch
|
|
2208
|
+
// (exclude pinned — backend returns all pinned on the first page,
|
|
2209
|
+
// bypassing pagination, so their time must not drag the cursor back).
|
|
2146
2210
|
const oldest = rows.reduce((min, s) => {
|
|
2147
|
-
if (s.pinned
|
|
2148
|
-
|
|
2211
|
+
if (s.pinned) return min;
|
|
2212
|
+
const t = s.updated_at || s.created_at;
|
|
2213
|
+
if (!t) return min;
|
|
2214
|
+
return (!min || t < min) ? t : min;
|
|
2149
2215
|
}, null);
|
|
2150
2216
|
if (oldest) _cronBefore = oldest;
|
|
2151
2217
|
_cronHasMore = !!data.has_more;
|
|
@@ -2154,12 +2220,13 @@ const Sessions = (() => {
|
|
|
2154
2220
|
console.error("loadMoreCron error:", e);
|
|
2155
2221
|
} finally {
|
|
2156
2222
|
_cronLoadingMore = false;
|
|
2157
|
-
Sessions.renderList(
|
|
2223
|
+
Sessions.renderList();
|
|
2158
2224
|
if (sidebarList) sidebarList.scrollTop = savedScrollTop;
|
|
2159
2225
|
}
|
|
2160
2226
|
},
|
|
2161
2227
|
|
|
2162
|
-
/** Commit current filter values
|
|
2228
|
+
/** Commit current filter values, fetch results, and render them into the
|
|
2229
|
+
* search overlay. Never touches the sidebar session list (_sessions). */
|
|
2163
2230
|
async commitSearch() {
|
|
2164
2231
|
const qEl = document.getElementById("session-search-q");
|
|
2165
2232
|
const typeEl = document.getElementById("session-search-type");
|
|
@@ -2169,13 +2236,19 @@ const Sessions = (() => {
|
|
|
2169
2236
|
if (dateEl) _filter.date = dateEl.dataset.value || "";
|
|
2170
2237
|
|
|
2171
2238
|
const token = ++_searchToken;
|
|
2172
|
-
|
|
2173
|
-
|
|
2239
|
+
const hasQuery = !!(_filter.q || _filter.date || _filter.type);
|
|
2240
|
+
// Empty filter → clear results, show the default hint instead.
|
|
2241
|
+
if (!hasQuery) {
|
|
2242
|
+
_searchResults = [];
|
|
2243
|
+
_searchSplit = null;
|
|
2244
|
+
Sessions._renderSearchResults({ state: "idle" });
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
Sessions._renderSearchResults({ state: "loading" });
|
|
2174
2249
|
|
|
2175
|
-
let
|
|
2176
|
-
let
|
|
2177
|
-
let nextCronCnt = 0;
|
|
2178
|
-
let nextSplit = null;
|
|
2250
|
+
let nextResults = [];
|
|
2251
|
+
let nextSplit = null;
|
|
2179
2252
|
|
|
2180
2253
|
try {
|
|
2181
2254
|
const baseParams = new URLSearchParams({ limit: "20" });
|
|
@@ -2207,34 +2280,24 @@ const Sessions = (() => {
|
|
|
2207
2280
|
s._matchVia = "content";
|
|
2208
2281
|
});
|
|
2209
2282
|
|
|
2210
|
-
|
|
2211
|
-
(contentData.sessions || []).forEach(s => { if (!nameIds.has(s.id))
|
|
2212
|
-
|
|
2213
|
-
nextCronCnt = nameData.cron_count || 0;
|
|
2214
|
-
nextSplit = { nameIds, contentIds, contentLoaded: contentRes.ok };
|
|
2283
|
+
nextResults = [...(nameData.sessions || [])];
|
|
2284
|
+
(contentData.sessions || []).forEach(s => { if (!nameIds.has(s.id)) nextResults.push(s); });
|
|
2285
|
+
nextSplit = { nameIds, contentIds, contentLoaded: contentRes.ok };
|
|
2215
2286
|
} else {
|
|
2216
2287
|
const res = await fetch(`/api/sessions?${baseParams}`);
|
|
2217
2288
|
if (token !== _searchToken) return;
|
|
2218
2289
|
if (!res.ok) return;
|
|
2219
2290
|
const data = await res.json();
|
|
2220
2291
|
if (token !== _searchToken) return;
|
|
2221
|
-
|
|
2222
|
-
nextHasMore = !!data.has_more;
|
|
2223
|
-
nextCronCnt = data.cron_count || 0;
|
|
2292
|
+
nextResults = data.sessions || [];
|
|
2224
2293
|
}
|
|
2225
2294
|
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
_hasMore = nextHasMore;
|
|
2229
|
-
_cronCount = nextCronCnt;
|
|
2230
|
-
_searchSplit = nextSplit;
|
|
2295
|
+
_searchResults = nextResults;
|
|
2296
|
+
_searchSplit = nextSplit;
|
|
2231
2297
|
} catch (e) {
|
|
2232
2298
|
if (token === _searchToken) console.error("commitSearch error:", e);
|
|
2233
2299
|
} finally {
|
|
2234
|
-
if (token === _searchToken)
|
|
2235
|
-
_loadingMore = false;
|
|
2236
|
-
Sessions.renderList();
|
|
2237
|
-
}
|
|
2300
|
+
if (token === _searchToken) Sessions._renderSearchResults();
|
|
2238
2301
|
}
|
|
2239
2302
|
},
|
|
2240
2303
|
|
|
@@ -2250,39 +2313,98 @@ const Sessions = (() => {
|
|
|
2250
2313
|
await Sessions.commitSearch();
|
|
2251
2314
|
},
|
|
2252
2315
|
|
|
2253
|
-
/**
|
|
2316
|
+
/** Render search results into the overlay's #session-search-results. */
|
|
2317
|
+
_renderSearchResults({ state = "results" } = {}) {
|
|
2318
|
+
const box = document.getElementById("session-search-results");
|
|
2319
|
+
if (!box) return;
|
|
2320
|
+
box.innerHTML = "";
|
|
2321
|
+
|
|
2322
|
+
if (state === "idle") {
|
|
2323
|
+
const hint = document.createElement("div");
|
|
2324
|
+
hint.className = "cmd-palette-hint";
|
|
2325
|
+
hint.textContent = I18n.t("sessions.search.hint");
|
|
2326
|
+
box.appendChild(hint);
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2329
|
+
if (state === "loading") {
|
|
2330
|
+
const ld = document.createElement("div");
|
|
2331
|
+
ld.className = "cmd-palette-hint";
|
|
2332
|
+
ld.textContent = I18n.t("sessions.search.loading");
|
|
2333
|
+
box.appendChild(ld);
|
|
2334
|
+
return;
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
if (_filter.q && _searchSplit) {
|
|
2338
|
+
const { nameIds, contentIds, contentLoaded } = _searchSplit;
|
|
2339
|
+
const nameRows = _searchResults.filter(s => nameIds.has(s.id));
|
|
2340
|
+
const contentRows = _searchResults.filter(s => contentIds.has(s.id));
|
|
2341
|
+
|
|
2342
|
+
if (nameRows.length > 0) {
|
|
2343
|
+
box.appendChild(_makeSearchHeader(I18n.t("sessions.search.byName", { n: nameRows.length })));
|
|
2344
|
+
nameRows.forEach(s => _renderSessionItem(box, s));
|
|
2345
|
+
}
|
|
2346
|
+
if (contentLoaded) {
|
|
2347
|
+
box.appendChild(_makeSearchHeader(I18n.t("sessions.search.byContent", { n: contentRows.length })));
|
|
2348
|
+
if (contentRows.length === 0) {
|
|
2349
|
+
const empty = document.createElement("div");
|
|
2350
|
+
empty.className = "session-empty";
|
|
2351
|
+
empty.textContent = I18n.t("sessions.search.contentEmpty");
|
|
2352
|
+
box.appendChild(empty);
|
|
2353
|
+
} else {
|
|
2354
|
+
contentRows.forEach(s => _renderSessionItem(box, s));
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
if (nameRows.length === 0 && contentRows.length === 0) {
|
|
2358
|
+
const empty = document.createElement("div");
|
|
2359
|
+
empty.className = "session-empty";
|
|
2360
|
+
empty.textContent = I18n.t("sessions.search.contentEmpty");
|
|
2361
|
+
box.appendChild(empty);
|
|
2362
|
+
}
|
|
2363
|
+
} else if (_searchResults.length === 0) {
|
|
2364
|
+
const empty = document.createElement("div");
|
|
2365
|
+
empty.className = "session-empty";
|
|
2366
|
+
empty.textContent = I18n.t("sessions.search.contentEmpty");
|
|
2367
|
+
box.appendChild(empty);
|
|
2368
|
+
} else {
|
|
2369
|
+
_searchResults.forEach(s => _renderSessionItem(box, s));
|
|
2370
|
+
}
|
|
2371
|
+
},
|
|
2372
|
+
|
|
2373
|
+
/** Open/close the command-palette search overlay. */
|
|
2254
2374
|
toggleSearch() {
|
|
2255
2375
|
_searchOpen = !_searchOpen;
|
|
2256
|
-
const
|
|
2257
|
-
const
|
|
2258
|
-
if (!
|
|
2376
|
+
const overlay = document.getElementById("session-search-overlay");
|
|
2377
|
+
const cmdbar = document.getElementById("header-cmdbar");
|
|
2378
|
+
if (!overlay) { _searchOpen = false; return; }
|
|
2259
2379
|
|
|
2260
2380
|
if (_searchOpen) {
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2381
|
+
overlay.hidden = false;
|
|
2382
|
+
// Force reflow so the open transition runs from the hidden state.
|
|
2383
|
+
void overlay.offsetWidth;
|
|
2384
|
+
overlay.classList.add("cmd-palette--open");
|
|
2385
|
+
cmdbar && cmdbar.classList.add("active");
|
|
2386
|
+
Sessions._renderSearchResults({ state: "idle" });
|
|
2265
2387
|
const inp = document.getElementById("session-search-q");
|
|
2266
2388
|
if (inp) setTimeout(() => inp.focus(), 30);
|
|
2267
2389
|
} else {
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
// After animation finishes, hide panel and reset inputs
|
|
2271
|
-
const hadActiveFilter = _filter.q || _filter.date || _filter.type;
|
|
2390
|
+
overlay.classList.remove("cmd-palette--open");
|
|
2391
|
+
cmdbar && cmdbar.classList.remove("active");
|
|
2272
2392
|
setTimeout(() => {
|
|
2273
|
-
|
|
2274
|
-
// Reset
|
|
2275
|
-
const qEl
|
|
2276
|
-
const dEl
|
|
2277
|
-
const tEl
|
|
2393
|
+
overlay.hidden = true;
|
|
2394
|
+
// Reset inputs + filter state so the next open starts clean.
|
|
2395
|
+
const qEl = document.getElementById("session-search-q");
|
|
2396
|
+
const dEl = document.getElementById("session-search-date");
|
|
2397
|
+
const tEl = document.getElementById("session-search-type");
|
|
2278
2398
|
if (qEl) qEl.value = "";
|
|
2279
2399
|
if (dEl) DatePicker.clear(dEl);
|
|
2280
2400
|
if (tEl) tEl.value = "";
|
|
2281
|
-
|
|
2401
|
+
const qClear = document.getElementById("btn-search-q-clear");
|
|
2402
|
+
if (qClear) qClear.hidden = true;
|
|
2282
2403
|
_filter.q = _filter.date = _filter.type = "";
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2404
|
+
_searchResults = [];
|
|
2405
|
+
_searchSplit = null;
|
|
2406
|
+
_searchToken++; // invalidate any in-flight request
|
|
2407
|
+
}, 160);
|
|
2286
2408
|
}
|
|
2287
2409
|
},
|
|
2288
2410
|
|
|
@@ -2352,8 +2474,9 @@ const Sessions = (() => {
|
|
|
2352
2474
|
|
|
2353
2475
|
/** Navigate to a session. Delegates panel switching to Router. */
|
|
2354
2476
|
select(id) {
|
|
2355
|
-
const s = _sessions.find(s => s.id === id);
|
|
2477
|
+
const s = _sessions.find(s => s.id === id) || _searchResults.find(s => s.id === id);
|
|
2356
2478
|
if (!s) return;
|
|
2479
|
+
if (_searchOpen) Sessions.toggleSearch(); // close palette on pick
|
|
2357
2480
|
Router.navigate("session", { id });
|
|
2358
2481
|
},
|
|
2359
2482
|
|
|
@@ -2402,49 +2525,24 @@ const Sessions = (() => {
|
|
|
2402
2525
|
|
|
2403
2526
|
// ── Rendering ─────────────────────────────────────────────────────────
|
|
2404
2527
|
|
|
2405
|
-
renderList({
|
|
2406
|
-
// Sort helper: pinned first, then
|
|
2528
|
+
renderList({ scrollToActive = false } = {}) {
|
|
2529
|
+
// Sort helper: pinned first, then most-recently-active by updated_at
|
|
2407
2530
|
const byPinnedAndTime = (a, b) => {
|
|
2408
2531
|
// Pinned sessions always come first
|
|
2409
2532
|
if (a.pinned && !b.pinned) return -1;
|
|
2410
2533
|
if (!a.pinned && b.pinned) return 1;
|
|
2411
|
-
// Within same pinned status, sort by
|
|
2412
|
-
const ta = a.
|
|
2413
|
-
const tb = b.
|
|
2414
|
-
return tb - ta;
|
|
2534
|
+
// Within same pinned status, sort by last activity (newest first)
|
|
2535
|
+
const ta = a.updated_at || a.created_at;
|
|
2536
|
+
const tb = b.updated_at || b.created_at;
|
|
2537
|
+
return new Date(tb || 0) - new Date(ta || 0);
|
|
2415
2538
|
};
|
|
2416
2539
|
|
|
2417
|
-
// ──
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
if (date) visible = visible.filter(s => (s.created_at || "").startsWith(date));
|
|
2421
|
-
if (type) {
|
|
2422
|
-
visible = type === "coding"
|
|
2423
|
-
? visible.filter(s => s.agent_profile === "coding")
|
|
2424
|
-
: visible.filter(s => s.source === type && s.agent_profile !== "coding");
|
|
2425
|
-
}
|
|
2426
|
-
|
|
2427
|
-
// ── Show/hide magnifier button ─────────────────────────────────────
|
|
2428
|
-
// Always visible when search panel is open; otherwise hide when < 10 sessions total.
|
|
2429
|
-
const togBtn = document.getElementById("btn-session-search-toggle");
|
|
2430
|
-
if (togBtn) togBtn.style.display = (_searchOpen || _sessions.length >= 10) ? "" : "none";
|
|
2431
|
-
|
|
2432
|
-
// ── Update filter UI: highlight active selects/date, show/hide clear button ──
|
|
2433
|
-
const typeEl = document.getElementById("session-search-type");
|
|
2434
|
-
const dateEl = document.getElementById("session-search-date");
|
|
2435
|
-
const clearAllBtn = document.getElementById("btn-search-clear-all");
|
|
2436
|
-
const qClearBtn = document.getElementById("btn-search-q-clear");
|
|
2437
|
-
if (typeEl) typeEl.dataset.active = _filter.type ? "true" : "false";
|
|
2438
|
-
if (dateEl) dateEl.dataset.active = _filter.date ? "true" : "false";
|
|
2439
|
-
const hasFilter = !!(_filter.type || _filter.date);
|
|
2440
|
-
if (clearAllBtn) clearAllBtn.hidden = !hasFilter;
|
|
2441
|
-
// ✕ inside the input — update based on current q value
|
|
2442
|
-
const qEl = document.getElementById("session-search-q");
|
|
2443
|
-
if (qClearBtn) qClearBtn.hidden = !(qEl && qEl.value);
|
|
2540
|
+
// ── Sidebar list always shows the full session set, sorted ───────
|
|
2541
|
+
// Search/filter no longer touches this list — it lives in the overlay.
|
|
2542
|
+
const visible = [..._sessions].sort(byPinnedAndTime);
|
|
2444
2543
|
|
|
2445
2544
|
// ── Split cron vs non-cron for folding ───────────────────────────
|
|
2446
|
-
const
|
|
2447
|
-
const isCronView = _cronView && !hasActiveFilter;
|
|
2545
|
+
const isCronView = _cronView;
|
|
2448
2546
|
const cronSessions = visible.filter(s => s.source === "cron");
|
|
2449
2547
|
|
|
2450
2548
|
// Update chat-section header based on view mode
|
|
@@ -2453,33 +2551,7 @@ const Sessions = (() => {
|
|
|
2453
2551
|
const list = $("session-list");
|
|
2454
2552
|
list.innerHTML = "";
|
|
2455
2553
|
|
|
2456
|
-
if (
|
|
2457
|
-
if (_filter.q && _searchSplit) {
|
|
2458
|
-
const { nameIds, contentIds, contentLoaded } = _searchSplit;
|
|
2459
|
-
const nameRows = visible.filter(s => nameIds.has(s.id));
|
|
2460
|
-
const contentRows = visible.filter(s => contentIds.has(s.id));
|
|
2461
|
-
|
|
2462
|
-
if (nameRows.length > 0) {
|
|
2463
|
-
list.appendChild(_makeSearchHeader(I18n.t("sessions.search.byName", { n: nameRows.length })));
|
|
2464
|
-
nameRows.forEach(s => _renderSessionItem(list, s));
|
|
2465
|
-
}
|
|
2466
|
-
if (contentLoaded) {
|
|
2467
|
-
list.appendChild(_makeSearchHeader(I18n.t("sessions.search.byContent", { n: contentRows.length })));
|
|
2468
|
-
if (contentRows.length === 0) {
|
|
2469
|
-
const empty = document.createElement("div");
|
|
2470
|
-
empty.className = "session-empty";
|
|
2471
|
-
empty.textContent = I18n.t("sessions.search.contentEmpty");
|
|
2472
|
-
list.appendChild(empty);
|
|
2473
|
-
} else {
|
|
2474
|
-
contentRows.forEach(s => _renderSessionItem(list, s));
|
|
2475
|
-
}
|
|
2476
|
-
}
|
|
2477
|
-
} else {
|
|
2478
|
-
visible.forEach(s => _renderSessionItem(list, s));
|
|
2479
|
-
}
|
|
2480
|
-
} else if (isCronView) {
|
|
2481
|
-
// Cron sub-view: show only cron sessions, paginated independently via
|
|
2482
|
-
// loadMoreCron() (the first page is fetched on entering the view).
|
|
2554
|
+
if (isCronView) {
|
|
2483
2555
|
// We never call the outer loadMore() here, so the outer list's cursor
|
|
2484
2556
|
// is left untouched. While the first page is in flight and nothing is
|
|
2485
2557
|
// loaded yet, show a loading placeholder instead of the empty state.
|
|
@@ -2534,8 +2606,13 @@ const Sessions = (() => {
|
|
|
2534
2606
|
list.appendChild(_makeLoadMoreBtn());
|
|
2535
2607
|
}
|
|
2536
2608
|
|
|
2537
|
-
// Scroll active session into view
|
|
2538
|
-
|
|
2609
|
+
// Scroll the active session into view ONLY when the caller explicitly
|
|
2610
|
+
// asks for it (i.e. the user just activated/switched to this session).
|
|
2611
|
+
// Plain re-renders triggered by content updates (status/cost/task changes
|
|
2612
|
+
// streamed in while an agent runs) must NOT move the sidebar — otherwise
|
|
2613
|
+
// they yank the list back to the active row and interrupt the user who
|
|
2614
|
+
// has scrolled away to browse other sessions.
|
|
2615
|
+
if (scrollToActive) {
|
|
2539
2616
|
const activeEl = list.querySelector(".session-item.active");
|
|
2540
2617
|
if (activeEl) {
|
|
2541
2618
|
// If the active session is the very first item, scroll to top of the sidebar
|
|
@@ -2807,7 +2884,7 @@ const Sessions = (() => {
|
|
|
2807
2884
|
// Status dot + text — first
|
|
2808
2885
|
const sibStatus = $("sib-status");
|
|
2809
2886
|
if (sibStatus) {
|
|
2810
|
-
sibStatus.
|
|
2887
|
+
sibStatus.innerHTML = `<span class="sib-dot"></span>${s.status || "idle"}`;
|
|
2811
2888
|
sibStatus.className = `sib-status-${s.status || "idle"}`;
|
|
2812
2889
|
}
|
|
2813
2890
|
|
|
@@ -3369,6 +3446,8 @@ const Sessions = (() => {
|
|
|
3369
3446
|
return text || I18n.t("chat.retrying");
|
|
3370
3447
|
} else if (progress_type === "idle_compress") {
|
|
3371
3448
|
return text || "Compressing...";
|
|
3449
|
+
} else if (progress_type === "vision") {
|
|
3450
|
+
return I18n.t("chat.vision");
|
|
3372
3451
|
}
|
|
3373
3452
|
return text || I18n.t("chat.thinking");
|
|
3374
3453
|
},
|
|
@@ -3616,10 +3695,18 @@ const Sessions = (() => {
|
|
|
3616
3695
|
// Populate model dropdown from configured models
|
|
3617
3696
|
_populateModelDropdown();
|
|
3618
3697
|
|
|
3619
|
-
// Set default working directory
|
|
3698
|
+
// Set default working directory to an absolute path (home/clacky_workspace).
|
|
3620
3699
|
const dirInput = $("new-session-directory");
|
|
3621
3700
|
if (dirInput && !dirInput.value) {
|
|
3622
|
-
|
|
3701
|
+
fetch("/api/dirs")
|
|
3702
|
+
.then(r => r.ok ? r.json() : null)
|
|
3703
|
+
.then(data => {
|
|
3704
|
+
const home = data && data.home;
|
|
3705
|
+
if (home && !dirInput.value) {
|
|
3706
|
+
dirInput.value = home.replace(/\/+$/, "") + "/clacky_workspace";
|
|
3707
|
+
}
|
|
3708
|
+
})
|
|
3709
|
+
.catch(() => {});
|
|
3623
3710
|
}
|
|
3624
3711
|
|
|
3625
3712
|
// Setup agent type change listener to show/hide init project checkbox
|
|
@@ -3854,10 +3941,11 @@ const Sessions = (() => {
|
|
|
3854
3941
|
|
|
3855
3942
|
try {
|
|
3856
3943
|
console.log("[Model Switcher] Fetching /api/config...");
|
|
3857
|
-
const res = await fetch(
|
|
3944
|
+
const res = await fetch(`/api/config?session_id=${encodeURIComponent(sessionId)}`);
|
|
3858
3945
|
const data = await res.json();
|
|
3859
3946
|
console.log("[Model Switcher] Received data:", data);
|
|
3860
3947
|
const models = data.models || [];
|
|
3948
|
+
const mediaCaps = data.media_capabilities || {};
|
|
3861
3949
|
console.log("[Model Switcher] Models count:", models.length);
|
|
3862
3950
|
|
|
3863
3951
|
if (models.length === 0) {
|
|
@@ -3919,6 +4007,17 @@ const Sessions = (() => {
|
|
|
3919
4007
|
nameLine.textContent = hasActiveOverride ? subInfo.current : m.model;
|
|
3920
4008
|
left.appendChild(nameLine);
|
|
3921
4009
|
|
|
4010
|
+
// Vision status for the active model only — follows whichever model is
|
|
4011
|
+
// currently in effect (mediaCaps is computed for that same model).
|
|
4012
|
+
if (m.id === currentModelId && mediaCaps.vision) {
|
|
4013
|
+
const ok = !!mediaCaps.vision.configured;
|
|
4014
|
+
const vis = document.createElement("span");
|
|
4015
|
+
vis.className = "sib-model-vision " + (ok ? "is-ok" : "is-missing");
|
|
4016
|
+
vis.textContent = ok ? I18n.t("sib.vision.ok") : I18n.t("sib.vision.missing");
|
|
4017
|
+
vis.title = ok ? I18n.t("sib.vision.okTip") : I18n.t("sib.vision.missingTip");
|
|
4018
|
+
left.appendChild(vis);
|
|
4019
|
+
}
|
|
4020
|
+
|
|
3922
4021
|
if (_nameCounts[m.model] > 1) {
|
|
3923
4022
|
left.classList.add("has-sub");
|
|
3924
4023
|
const host = (() => {
|
|
@@ -3979,6 +4078,8 @@ const Sessions = (() => {
|
|
|
3979
4078
|
opt.addEventListener("click", () => _switchModel(sessionId, m.id, m.model));
|
|
3980
4079
|
dropdown.appendChild(opt);
|
|
3981
4080
|
});
|
|
4081
|
+
|
|
4082
|
+
_appendGenerationFooter(dropdown, mediaCaps);
|
|
3982
4083
|
console.log("[Model Switcher] Dropdown populated, children count:", dropdown.children.length);
|
|
3983
4084
|
} catch (e) {
|
|
3984
4085
|
console.error("Failed to load models:", e);
|
|
@@ -3986,6 +4087,56 @@ const Sessions = (() => {
|
|
|
3986
4087
|
}
|
|
3987
4088
|
}
|
|
3988
4089
|
|
|
4090
|
+
// Footer for image/video/audio generation. These come only from dedicated
|
|
4091
|
+
// sidecar models (the chat model can't generate media). We always show all
|
|
4092
|
+
// three kinds — configured ones highlighted, the rest dimmed — plus a single
|
|
4093
|
+
// Settings entry so users can add or change a generation model.
|
|
4094
|
+
function _appendGenerationFooter(dropdown, mediaCaps) {
|
|
4095
|
+
const kinds = ["image", "video", "audio"];
|
|
4096
|
+
const footer = document.createElement("div");
|
|
4097
|
+
footer.className = "sib-gen-footer";
|
|
4098
|
+
|
|
4099
|
+
const list = document.createElement("span");
|
|
4100
|
+
list.className = "sib-gen-list";
|
|
4101
|
+
kinds.forEach(k => {
|
|
4102
|
+
const cap = mediaCaps[k] || {};
|
|
4103
|
+
const ok = !!cap.configured;
|
|
4104
|
+
const chip = document.createElement("span");
|
|
4105
|
+
chip.className = "sib-gen-chip " + (ok ? "is-ok" : "is-off");
|
|
4106
|
+
chip.textContent = (ok ? "✓ " : "") + I18n.t(`sib.gen.kind.${k}`);
|
|
4107
|
+
chip.title = ok
|
|
4108
|
+
? I18n.t("sib.gen.okTip", { model: cap.model || "" })
|
|
4109
|
+
: I18n.t("sib.gen.offTip");
|
|
4110
|
+
list.appendChild(chip);
|
|
4111
|
+
});
|
|
4112
|
+
footer.appendChild(list);
|
|
4113
|
+
|
|
4114
|
+
const configBtn = document.createElement("button");
|
|
4115
|
+
configBtn.type = "button";
|
|
4116
|
+
configBtn.className = "sib-gen-config";
|
|
4117
|
+
configBtn.textContent = I18n.t("sib.gen.config");
|
|
4118
|
+
configBtn.title = I18n.t("sib.gen.offTip");
|
|
4119
|
+
configBtn.addEventListener("click", (ev) => {
|
|
4120
|
+
ev.stopPropagation();
|
|
4121
|
+
_goConfigureMedia();
|
|
4122
|
+
});
|
|
4123
|
+
footer.appendChild(configBtn);
|
|
4124
|
+
|
|
4125
|
+
dropdown.appendChild(footer);
|
|
4126
|
+
}
|
|
4127
|
+
|
|
4128
|
+
function _goConfigureMedia() {
|
|
4129
|
+
const dropdown = $("sib-model-dropdown");
|
|
4130
|
+
if (dropdown) dropdown.style.display = "none";
|
|
4131
|
+
_isOpen = false;
|
|
4132
|
+
_closeSubmodelPanel();
|
|
4133
|
+
if (typeof Router !== "undefined") Router.navigate("settings");
|
|
4134
|
+
setTimeout(() => {
|
|
4135
|
+
const sec = document.getElementById("media-section");
|
|
4136
|
+
if (sec) sec.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
4137
|
+
}, 200);
|
|
4138
|
+
}
|
|
4139
|
+
|
|
3989
4140
|
// Render one latency cell based on a cached result.
|
|
3990
4141
|
// undefined → empty slot (never tested / in-flight starts from here)
|
|
3991
4142
|
// { ok:true } → "812ms" in green/amber/red per threshold
|
|
@@ -4109,9 +4260,12 @@ const Sessions = (() => {
|
|
|
4109
4260
|
throw new Error(data.error || "Unknown error");
|
|
4110
4261
|
}
|
|
4111
4262
|
|
|
4112
|
-
//
|
|
4113
|
-
|
|
4114
|
-
|
|
4263
|
+
// The status bar is updated by the session_update broadcast that the
|
|
4264
|
+
// backend emits inside this same request. Don't touch sibModel here:
|
|
4265
|
+
// the broadcast typically arrives BEFORE this fetch resolves (WS frame
|
|
4266
|
+
// vs HTTP response on separate TCP streams), so writing here would
|
|
4267
|
+
// overwrite the already-correct value with an incomplete one (this
|
|
4268
|
+
// function only knows the card name, not whether a sub-model is pinned).
|
|
4115
4269
|
|
|
4116
4270
|
console.log(`Switched session ${sessionId} to model ${modelName} (${modelId})`);
|
|
4117
4271
|
} catch (e) {
|
|
@@ -4139,11 +4293,10 @@ const Sessions = (() => {
|
|
|
4139
4293
|
const data = await res.json();
|
|
4140
4294
|
if (!res.ok) throw new Error(data.error || "Unknown error");
|
|
4141
4295
|
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
}
|
|
4296
|
+
// The status bar is updated by the session_update broadcast that the
|
|
4297
|
+
// backend emits inside this same request. Don't touch sibModel here:
|
|
4298
|
+
// the broadcast typically arrives BEFORE this fetch resolves, so any
|
|
4299
|
+
// write here would race with (and often overwrite) the correct value.
|
|
4147
4300
|
} catch (e) {
|
|
4148
4301
|
console.error("Failed to switch sub-model:", e);
|
|
4149
4302
|
alert("Failed to switch sub-model: " + e.message);
|
|
@@ -4272,13 +4425,33 @@ const Sessions = (() => {
|
|
|
4272
4425
|
return (s && s !== key) ? s : fallback;
|
|
4273
4426
|
};
|
|
4274
4427
|
|
|
4428
|
+
// When no session exists yet (e.g. the New Session modal), browse the
|
|
4429
|
+
// real filesystem via /api/dirs instead of the session-scoped files API.
|
|
4430
|
+
const sessionLess = !sessionId;
|
|
4431
|
+
|
|
4275
4432
|
let selectedPath = currentDir;
|
|
4276
4433
|
let rootDir = ""; // absolute path of the session's working directory
|
|
4434
|
+
let homeDir = ""; // user home, used as the "working directory" preset when session-less
|
|
4435
|
+
let showHidden = false;
|
|
4277
4436
|
|
|
4278
4437
|
// Fetch directory entries from API, returns dirs with absolute paths
|
|
4279
4438
|
async function fetchDirs(relPath, absolute = false) {
|
|
4439
|
+
if (sessionLess) {
|
|
4440
|
+
// /api/dirs already returns absolute paths and operates in absolute mode.
|
|
4441
|
+
let url = `/api/dirs${relPath ? `?path=${encodeURIComponent(relPath)}` : ""}`;
|
|
4442
|
+
if (showHidden) url += `${url.includes("?") ? "&" : "?"}show_hidden=true`;
|
|
4443
|
+
const resp = await fetch(url);
|
|
4444
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
4445
|
+
const data = await resp.json();
|
|
4446
|
+
rootDir = data.root || rootDir;
|
|
4447
|
+
homeDir = data.home || homeDir;
|
|
4448
|
+
const dirs = (data.entries || []).filter(e => e.type === "dir");
|
|
4449
|
+
dirs.forEach(d => { d.absPath = d.path; d.absolute = true; });
|
|
4450
|
+
return dirs;
|
|
4451
|
+
}
|
|
4280
4452
|
let url = `/api/sessions/${encodeURIComponent(sessionId)}/files?path=${encodeURIComponent(relPath || "")}`;
|
|
4281
4453
|
if (absolute) url += "&absolute=true";
|
|
4454
|
+
if (showHidden) url += "&show_hidden=true";
|
|
4282
4455
|
const resp = await fetch(url);
|
|
4283
4456
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
4284
4457
|
const data = await resp.json();
|
|
@@ -4397,7 +4570,9 @@ const Sessions = (() => {
|
|
|
4397
4570
|
// Title
|
|
4398
4571
|
const title = document.createElement("div");
|
|
4399
4572
|
title.className = "modal-title";
|
|
4400
|
-
title.textContent =
|
|
4573
|
+
title.textContent = sessionLess
|
|
4574
|
+
? t("sessions.modal.dirpicker.title", "选择工作目录")
|
|
4575
|
+
: t("sib.dir.changePrompt", "切换工作目录");
|
|
4401
4576
|
modal.appendChild(title);
|
|
4402
4577
|
|
|
4403
4578
|
// Modal body
|
|
@@ -4414,12 +4589,42 @@ const Sessions = (() => {
|
|
|
4414
4589
|
presets.className = "dp-presets";
|
|
4415
4590
|
body.appendChild(presets);
|
|
4416
4591
|
|
|
4592
|
+
// "Up one level" button — navigates to the parent of the current path.
|
|
4593
|
+
const upBtn = document.createElement("button");
|
|
4594
|
+
upBtn.className = "btn btn-secondary btn-sm dp-up-btn";
|
|
4595
|
+
upBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg><span>${t("sib.dir.up", "上一级")}</span>`;
|
|
4596
|
+
function parentOf(p) {
|
|
4597
|
+
const trimmed = (p || "").replace(/\/+$/, "");
|
|
4598
|
+
if (!trimmed || trimmed === "/" ) return null;
|
|
4599
|
+
const idx = trimmed.lastIndexOf("/");
|
|
4600
|
+
if (idx < 0) return null;
|
|
4601
|
+
return idx === 0 ? "/" : trimmed.substring(0, idx);
|
|
4602
|
+
}
|
|
4603
|
+
function refreshUpBtn() {
|
|
4604
|
+
const parent = parentOf(pathInput.value);
|
|
4605
|
+
upBtn.disabled = !parent;
|
|
4606
|
+
}
|
|
4607
|
+
upBtn.addEventListener("click", () => {
|
|
4608
|
+
const parent = parentOf(pathInput.value);
|
|
4609
|
+
if (!parent) return;
|
|
4610
|
+
selectedPath = parent;
|
|
4611
|
+
pathInput.value = parent;
|
|
4612
|
+
modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
|
|
4613
|
+
loadTreeForPath(parent, true);
|
|
4614
|
+
});
|
|
4615
|
+
|
|
4417
4616
|
function setupPresets() {
|
|
4418
4617
|
presets.innerHTML = "";
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4618
|
+
presets.appendChild(upBtn);
|
|
4619
|
+
const presetDirs = sessionLess
|
|
4620
|
+
? [
|
|
4621
|
+
{ value: homeDir, text: t("sib.dir.home", "主目录"), absolute: true },
|
|
4622
|
+
{ value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
|
|
4623
|
+
]
|
|
4624
|
+
: [
|
|
4625
|
+
{ value: rootDir, text: t("sib.dir.current", "当前工作目录"), absolute: false },
|
|
4626
|
+
{ value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
|
|
4627
|
+
]; presetDirs.forEach(p => {
|
|
4423
4628
|
const btn = document.createElement("button");
|
|
4424
4629
|
btn.className = "btn btn-secondary btn-sm";
|
|
4425
4630
|
btn.textContent = p.text;
|
|
@@ -4439,6 +4644,9 @@ const Sessions = (() => {
|
|
|
4439
4644
|
const pathInput = document.createElement("input");
|
|
4440
4645
|
pathInput.type = "text";
|
|
4441
4646
|
pathInput.className = "dir-picker-input";
|
|
4647
|
+
pathInput.spellcheck = false;
|
|
4648
|
+
pathInput.autocomplete = "off";
|
|
4649
|
+
pathInput.setAttribute("autocapitalize", "off");
|
|
4442
4650
|
pathInput.value = currentDir;
|
|
4443
4651
|
pathInput.placeholder = t("sib.dir.inputPlaceholder", "输入或选择目录路径");
|
|
4444
4652
|
pathContainer.appendChild(pathInput);
|
|
@@ -4450,6 +4658,19 @@ const Sessions = (() => {
|
|
|
4450
4658
|
pathContainer.appendChild(autocomplete);
|
|
4451
4659
|
|
|
4452
4660
|
body.appendChild(pathContainer);
|
|
4661
|
+
|
|
4662
|
+
// Show hidden files toggle
|
|
4663
|
+
const hiddenToggle = document.createElement("label");
|
|
4664
|
+
hiddenToggle.className = "dp-hidden-toggle";
|
|
4665
|
+
const hiddenCheckbox = document.createElement("input");
|
|
4666
|
+
hiddenCheckbox.type = "checkbox";
|
|
4667
|
+
hiddenCheckbox.checked = false;
|
|
4668
|
+
const hiddenLabelText = document.createElement("span");
|
|
4669
|
+
hiddenLabelText.textContent = t("sib.dir.showHidden", "显示隐藏文件");
|
|
4670
|
+
hiddenToggle.appendChild(hiddenCheckbox);
|
|
4671
|
+
hiddenToggle.appendChild(hiddenLabelText);
|
|
4672
|
+
body.appendChild(hiddenToggle);
|
|
4673
|
+
|
|
4453
4674
|
// Tree container
|
|
4454
4675
|
const treeContainer = document.createElement("div");
|
|
4455
4676
|
treeContainer.className = "dp-tree";
|
|
@@ -4492,17 +4713,19 @@ const Sessions = (() => {
|
|
|
4492
4713
|
pathInput.addEventListener("input", () => {
|
|
4493
4714
|
selectedPath = pathInput.value;
|
|
4494
4715
|
modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
|
|
4716
|
+
refreshUpBtn();
|
|
4495
4717
|
});
|
|
4496
4718
|
|
|
4497
4719
|
// Load tree for a given path
|
|
4498
4720
|
async function loadTreeForPath(dirPath, absolute = false) {
|
|
4499
4721
|
treeContainer.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
|
|
4500
4722
|
if (dirPath) { pathInput.value = dirPath; selectedPath = dirPath; }
|
|
4501
|
-
//
|
|
4502
|
-
|
|
4723
|
+
// Session-less mode browses absolute paths directly via /api/dirs;
|
|
4724
|
+
// skip the working-directory-relative path math.
|
|
4725
|
+
const useAbsolute = sessionLess || absolute || (dirPath.startsWith("/") && (!rootDir || !(dirPath === rootDir || dirPath.startsWith(rootDir + "/"))));
|
|
4503
4726
|
// Convert absolute path to relative path for API
|
|
4504
4727
|
let relPath = dirPath;
|
|
4505
|
-
if (!useAbsolute && rootDir && (dirPath === rootDir || dirPath.startsWith(rootDir + "/"))) {
|
|
4728
|
+
if (!sessionLess && !useAbsolute && rootDir && (dirPath === rootDir || dirPath.startsWith(rootDir + "/"))) {
|
|
4506
4729
|
relPath = dirPath.substring(rootDir.length).replace(/^\/+/, "");
|
|
4507
4730
|
}
|
|
4508
4731
|
try {
|
|
@@ -4524,12 +4747,32 @@ const Sessions = (() => {
|
|
|
4524
4747
|
dirs.forEach(d => frag.appendChild(buildDirNode(d, 0)));
|
|
4525
4748
|
treeContainer.appendChild(frag);
|
|
4526
4749
|
}
|
|
4750
|
+
refreshUpBtn();
|
|
4527
4751
|
} catch (err) {
|
|
4528
4752
|
console.error("dir picker load failed:", err);
|
|
4529
4753
|
treeContainer.innerHTML = `<div class="dp-error">${t("sib.dir.loadError", "加载失败")}</div>`;
|
|
4530
4754
|
}
|
|
4531
4755
|
}
|
|
4532
|
-
|
|
4756
|
+
// Session-less mode: start browsing from the requested default (e.g.
|
|
4757
|
+
// ~/clacky_workspace). The backend walks up to the nearest existing
|
|
4758
|
+
// ancestor if it doesn't exist yet, while pathInput keeps the original.
|
|
4759
|
+
if (sessionLess && currentDir) {
|
|
4760
|
+
const wanted = currentDir;
|
|
4761
|
+
loadTreeForPath(currentDir, true).then(() => {
|
|
4762
|
+
pathInput.value = wanted;
|
|
4763
|
+
selectedPath = wanted;
|
|
4764
|
+
refreshUpBtn();
|
|
4765
|
+
});
|
|
4766
|
+
} else {
|
|
4767
|
+
loadTreeForPath("");
|
|
4768
|
+
}
|
|
4769
|
+
|
|
4770
|
+
hiddenCheckbox.addEventListener("change", () => {
|
|
4771
|
+
showHidden = hiddenCheckbox.checked;
|
|
4772
|
+
const cur = pathInput.value.trim();
|
|
4773
|
+
const absolute = sessionLess || cur.startsWith("/");
|
|
4774
|
+
loadTreeForPath(cur, absolute);
|
|
4775
|
+
});
|
|
4533
4776
|
|
|
4534
4777
|
// ── Autocomplete logic ──────────────────────────────────────────────
|
|
4535
4778
|
let autocompleteTimer = null;
|
|
@@ -4700,10 +4943,6 @@ const Sessions = (() => {
|
|
|
4700
4943
|
overlay.addEventListener("click", (e) => {
|
|
4701
4944
|
if (e.target === overlay) cancelButton.click();
|
|
4702
4945
|
});
|
|
4703
|
-
|
|
4704
|
-
// Focus path input
|
|
4705
|
-
pathInput.focus();
|
|
4706
|
-
pathInput.select();
|
|
4707
4946
|
});
|
|
4708
4947
|
}
|
|
4709
4948
|
|
|
@@ -4888,6 +5127,11 @@ const Sessions = (() => {
|
|
|
4888
5127
|
}
|
|
4889
5128
|
}
|
|
4890
5129
|
|
|
5130
|
+
// Expose the picker so other modules (e.g. the New Session modal binding in
|
|
5131
|
+
// the Sessions IIFE) can reuse it. Named distinctly to avoid colliding with
|
|
5132
|
+
// the native window.showDirectoryPicker File System Access API.
|
|
5133
|
+
window.openDirectoryPicker = showDirectoryPicker;
|
|
5134
|
+
|
|
4891
5135
|
})();
|
|
4892
5136
|
|
|
4893
5137
|
// ── Session Info Bar Reasoning Effort Switcher ────────────────────────────
|