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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/lib/clacky/agent/skill_manager.rb +1 -1
  4. data/lib/clacky/agent/time_machine.rb +256 -74
  5. data/lib/clacky/agent/tool_executor.rb +12 -0
  6. data/lib/clacky/agent.rb +21 -31
  7. data/lib/clacky/agent_config.rb +18 -0
  8. data/lib/clacky/cli.rb +55 -3
  9. data/lib/clacky/default_skills/media-gen/SKILL.md +173 -5
  10. data/lib/clacky/default_skills/skill-creator/SKILL.md +1 -0
  11. data/lib/clacky/media/base.rb +125 -0
  12. data/lib/clacky/media/dashscope.rb +243 -0
  13. data/lib/clacky/media/gemini.rb +10 -0
  14. data/lib/clacky/media/generator.rb +75 -0
  15. data/lib/clacky/media/openai_compat.rb +160 -0
  16. data/lib/clacky/message_history.rb +12 -7
  17. data/lib/clacky/providers.rb +28 -0
  18. data/lib/clacky/rich_ui_controller.rb +3 -1
  19. data/lib/clacky/server/backup_manager.rb +200 -0
  20. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -2
  21. data/lib/clacky/server/channel/adapters/feishu/bot.rb +68 -15
  22. data/lib/clacky/server/channel/channel_manager.rb +180 -81
  23. data/lib/clacky/server/http_server.rb +348 -15
  24. data/lib/clacky/server/scheduler.rb +19 -0
  25. data/lib/clacky/server/session_registry.rb +8 -4
  26. data/lib/clacky/session_manager.rb +40 -2
  27. data/lib/clacky/skill.rb +3 -1
  28. data/lib/clacky/tools/trash_manager.rb +14 -0
  29. data/lib/clacky/ui2/components/command_suggestions.rb +1 -0
  30. data/lib/clacky/ui2/components/modal_component.rb +34 -7
  31. data/lib/clacky/ui2/ui_controller.rb +150 -19
  32. data/lib/clacky/utils/file_processor.rb +75 -4
  33. data/lib/clacky/version.rb +1 -1
  34. data/lib/clacky/web/app.css +2038 -1147
  35. data/lib/clacky/web/app.js +22 -1
  36. data/lib/clacky/web/backup.js +119 -0
  37. data/lib/clacky/web/billing.js +94 -7
  38. data/lib/clacky/web/channels.js +81 -11
  39. data/lib/clacky/web/design-sample.css +247 -0
  40. data/lib/clacky/web/design-sample.html +127 -0
  41. data/lib/clacky/web/favicon.svg +16 -0
  42. data/lib/clacky/web/i18n.js +159 -31
  43. data/lib/clacky/web/index.html +175 -55
  44. data/lib/clacky/web/logo_nav_dark.png +0 -0
  45. data/lib/clacky/web/onboard.js +114 -28
  46. data/lib/clacky/web/sessions.js +436 -192
  47. data/lib/clacky/web/settings.js +21 -1
  48. data/lib/clacky/web/skills.js +6 -6
  49. data/lib/clacky/web/tasks.js +129 -61
  50. data/lib/clacky/web/utils.js +72 -0
  51. data/lib/clacky/web/ws-dispatcher.js +6 -0
  52. data/lib/clacky.rb +1 -0
  53. metadata +8 -3
  54. data/lib/clacky/server/channel/group_message_buffer.rb +0 -53
@@ -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 server)
28
- let _searchOpen = false; // is the search panel visible?
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
- // (Removed dead binding for `new-session-browse-btn` no such element
478
- // exists in index.html. Originally guarded by `if ($(...))`; deleting the
479
- // defense exposed that it never ran. Native file-browser picker is not
480
- // implemented on the web UI — users type a path directly.)
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
- // Magnifier toggle button
893
+ // Open the palette: top cmdbar button (or ⌘K, bound below).
870
894
  document.addEventListener("click", (e) => {
871
- if (e.target && e.target.closest("#btn-session-search-toggle")) {
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 panel
900
+ // Close button inside palette.
877
901
  document.addEventListener("click", (e) => {
878
- if (e.target && e.target.id === "btn-session-search-close") {
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
- // Enter key commit search.
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 in search input
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
- if (!details) return;
1084
- const isHidden = details.style.display === "none";
1085
- if (isHidden) {
1086
- if (!details.dataset.filled) {
1087
- const json = item.dataset.argsJson || "";
1088
- details.textContent = json;
1089
- details.dataset.filled = "1";
1090
- }
1091
- details.style.display = "";
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
- function _completeLastToolItem(group, result) {
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
- stdout.style.display = "";
1221
- } else if (!existing && !resultStr) {
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
- stdout.style.display = "";
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 doesn't jump back to the active
2072
- // session when renderList() forces scroll-to-active.
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({ skipScrollToActive: true });
2137
+ Sessions.renderList();
2076
2138
 
2077
2139
  try {
2078
- // Cursor: oldest created_at in the current list, EXCLUDING pinned
2079
- // sessions. The backend always returns ALL pinned sessions on the
2080
- // first page (they bypass pagination), so their created_at is
2081
- // irrelevant for cursor calculation. Including them here would
2082
- // cause the cursor to jump too far back and skip sessions between
2083
- // the oldest pinned one and the real last-loaded non-pinned row.
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
- if (!s.created_at) return min;
2087
- return (!min || s.created_at < min) ? s.created_at : min;
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({ skipScrollToActive: true });
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({ skipScrollToActive: true });
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 created_at in THIS batch (exclude
2144
- // pinned — backend returns all pinned on the first page, bypassing
2145
- // pagination, so their created_at must not drag the cursor back).
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 || !s.created_at) return min;
2148
- return (!min || s.created_at < min) ? s.created_at : min;
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({ skipScrollToActive: true });
2223
+ Sessions.renderList();
2158
2224
  if (sidebarList) sidebarList.scrollTop = savedScrollTop;
2159
2225
  }
2160
2226
  },
2161
2227
 
2162
- /** Commit current filter values and re-fetch from server. Called by Enter / Go button. */
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
- _loadingMore = true;
2173
- Sessions.renderList({ skipScrollToActive: true });
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 nextSessions = [];
2176
- let nextHasMore = false;
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
- nextSessions = [...(nameData.sessions || [])];
2211
- (contentData.sessions || []).forEach(s => { if (!nameIds.has(s.id)) nextSessions.push(s); });
2212
- nextHasMore = false;
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
- nextSessions = data.sessions || [];
2222
- nextHasMore = !!data.has_more;
2223
- nextCronCnt = data.cron_count || 0;
2292
+ nextResults = data.sessions || [];
2224
2293
  }
2225
2294
 
2226
- _sessions.length = 0;
2227
- _sessions.push(...nextSessions);
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
- /** Toggle the search panel open/closed. */
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 panel = document.getElementById("session-search-bar");
2257
- const togBtn = document.getElementById("btn-session-search-toggle");
2258
- if (!panel) return;
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
- panel.hidden = false;
2262
- panel.classList.add("search-panel--open");
2263
- togBtn && togBtn.classList.add("active");
2264
- // Auto-focus the text input
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
- panel.classList.remove("search-panel--open");
2269
- togBtn && togBtn.classList.remove("active");
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
- panel.hidden = true;
2274
- // Reset DOM inputs
2275
- const qEl = document.getElementById("session-search-q");
2276
- const dEl = document.getElementById("session-search-date");
2277
- const tEl = document.getElementById("session-search-type");
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
- // Clear filter state
2401
+ const qClear = document.getElementById("btn-search-q-clear");
2402
+ if (qClear) qClear.hidden = true;
2282
2403
  _filter.q = _filter.date = _filter.type = "";
2283
- // Only re-fetch if a filter was actually active (avoids pointless reload)
2284
- if (hadActiveFilter) Sessions.commitSearch();
2285
- }, 180);
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({ skipScrollToActive = false } = {}) {
2406
- // Sort helper: pinned first, then newest-first by created_at
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 time (newest first)
2412
- const ta = a.created_at ? new Date(a.created_at) : 0;
2413
- const tb = b.created_at ? new Date(b.created_at) : 0;
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
- // ── Apply client-side filter (mirrors server params for instant feedback)
2418
- const { q, date, type } = _filter;
2419
- let visible = [..._sessions].sort(byPinnedAndTime);
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 hasActiveFilter = !!(_filter.q || _filter.type || _filter.date);
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 (hasActiveFilter) {
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 so the sidebar always shows the current session.
2538
- if (!skipScrollToActive) {
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.textContent = `● ${s.status || "idle"}`;
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
- dirInput.value = "~/clacky_workspace";
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("/api/config");
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
- // Update UI optimistically (will be confirmed by session_update broadcast)
4113
- const sibModel = $("sib-model");
4114
- if (sibModel) sibModel.textContent = modelName;
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
- const sibModel = $("sib-model");
4143
- if (sibModel) {
4144
- sibModel.textContent = displayName || "";
4145
- sibModel.dataset.subModel = modelName || "";
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 = t("sib.dir.changePrompt", "切换工作目录");
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
- const presetDirs = [
4420
- { value: rootDir, text: t("sib.dir.current", "当前工作目录"), absolute: false },
4421
- { value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
4422
- ]; presetDirs.forEach(p => {
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
- // Auto-detect absolute mode: path is absolute and outside working directory
4502
- const useAbsolute = absolute || (dirPath.startsWith("/") && (!rootDir || !(dirPath === rootDir || dirPath.startsWith(rootDir + "/"))));
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
- loadTreeForPath("");
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 ────────────────────────────