clay-server 5.0.0-beta.2 → 5.0.0-beta.3

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 (50) hide show
  1. package/lib/daemon.js +9 -0
  2. package/lib/new-session-default.js +45 -0
  3. package/lib/project-connection.js +4 -2
  4. package/lib/project-session-creation.js +107 -0
  5. package/lib/project-session-delete.js +21 -3
  6. package/lib/project-session-folder-delete.js +169 -0
  7. package/lib/project-session-folders.js +272 -0
  8. package/lib/project-sessions.js +46 -26
  9. package/lib/project.js +2 -0
  10. package/lib/public/app.js +10 -0
  11. package/lib/public/css/filebrowser.css +3 -3
  12. package/lib/public/css/mates.css +1 -1
  13. package/lib/public/css/mobile-nav.css +0 -128
  14. package/lib/public/css/session-folders.css +383 -0
  15. package/lib/public/css/sidebar.css +28 -545
  16. package/lib/public/index.html +3 -21
  17. package/lib/public/modules/app-favicon.js +5 -4
  18. package/lib/public/modules/app-messages.js +13 -0
  19. package/lib/public/modules/input.js +3 -0
  20. package/lib/public/modules/project-switcher.js +4 -4
  21. package/lib/public/modules/session-create-form.js +428 -0
  22. package/lib/public/modules/session-create.js +338 -0
  23. package/lib/public/modules/session-folder-collapse.js +192 -0
  24. package/lib/public/modules/session-folder-context.js +215 -0
  25. package/lib/public/modules/session-folder-delete.js +313 -0
  26. package/lib/public/modules/session-folder-dialogs.js +173 -0
  27. package/lib/public/modules/session-folder-dnd.js +214 -0
  28. package/lib/public/modules/session-folder-draft.js +184 -0
  29. package/lib/public/modules/session-folder-layout.js +133 -0
  30. package/lib/public/modules/session-folder-toolbar.js +185 -0
  31. package/lib/public/modules/session-folder-view-menu.js +187 -0
  32. package/lib/public/modules/session-folders.js +95 -0
  33. package/lib/public/modules/session-list-search.js +108 -0
  34. package/lib/public/modules/session-presence-ui.js +44 -0
  35. package/lib/public/modules/sidebar-mobile.js +38 -182
  36. package/lib/public/modules/sidebar-session-folders-ui.js +382 -0
  37. package/lib/public/modules/sidebar-sessions.js +141 -718
  38. package/lib/public/modules/sidebar.js +10 -2
  39. package/lib/public/modules/terminal.js +2 -2
  40. package/lib/public/modules/tool-palette.js +37 -219
  41. package/lib/public/style.css +1 -0
  42. package/lib/server.js +4 -0
  43. package/lib/session-folders.js +414 -0
  44. package/lib/sessions.js +7 -5
  45. package/lib/users-session-folder-preferences.js +69 -0
  46. package/lib/users.js +6 -0
  47. package/lib/ws-schema.js +11 -0
  48. package/package.json +1 -1
  49. package/lib/public/modules/session-creation-cta.js +0 -46
  50. package/lib/public/modules/tool-palette-overlays.js +0 -159
@@ -2,7 +2,6 @@ import { appendGithubWork } from './session-github.js';
2
2
  // sidebar-sessions.js - Session list, search, presence, countdown, CLI picker
3
3
  // Extracted from sidebar.js (PR-35)
4
4
 
5
- import { avatarUrl, userAvatarUrl } from './avatar.js';
6
5
  import { escapeHtml } from './utils.js';
7
6
  import { iconHtml, refreshIcons } from './icons.js';
8
7
  import { openSearch as openSessionSearch } from './session-search.js';
@@ -13,15 +12,21 @@ import { dismissOverlayPanels, closeSidebar, updatePageTitle, spawnDustParticles
13
12
  import { showConfirm } from './app-misc.js';
14
13
  import { getUpcomingSchedules } from './scheduler.js';
15
14
  import { refreshMobileChatSheet } from './sidebar-mobile.js';
16
- import { VENDOR_AVATARS, VENDOR_NAMES, VENDOR_ORDER, VENDOR_HOMEPAGES, isExperimentalVendor } from './app-rendering.js';
15
+ import { VENDOR_AVATARS, VENDOR_NAMES } from './app-rendering.js';
17
16
  import { openGroup, separateGroup } from './split-view.js';
18
17
  import { groupedSessionIds } from './split-group-helpers.js';
19
- import { defaultVendorUnavailable, resolvePreferredVendor, saveDefaultVendor } from './default-vendor.js';
20
- import { openPairDialog } from './split-pair-ui.js';
21
- import { renderSessionCreationCta } from './session-creation-cta.js';
22
- import { describeSessionDelete, describeGroupDelete } from './session-delete-scope.js';
18
+ import { resolvePreferredVendor } from './default-vendor.js';
19
+ import {
20
+ canOrganizeSession, computeLayout, createStarButton, isFavoriteSession, openMoveToFolder, renderFavoritesSection,
21
+ renderOrdinarySections, setupFolderDrag, toggleFavorite
22
+ } from './sidebar-session-folders-ui.js';
23
+ import { renderFolderToolbar, captureFolderInputs, folderToolbarNeedsRender } from './session-folder-toolbar.js';
24
+ import { captureFolderCollapseAnimations } from './session-folder-collapse.js';
25
+ import { getListSearchQuery, getListSearchMatchIds, applyListSearchResults, filterItemsBySearch } from './session-list-search.js';
26
+ import { expectCreatedSession } from './session-create.js';
27
+ import { describeSessionDelete } from './session-delete-scope.js';
28
+ import { renderSessionPresence, setSessionPresence } from './session-presence-ui.js';
23
29
  import {
24
- hierarchyItemMatches,
25
30
  prepareSidebarHierarchy,
26
31
  renderDesktopDriverHierarchy,
27
32
  renderDesktopOrphanHierarchy
@@ -30,15 +35,9 @@ import {
30
35
 
31
36
  // --- Session state ---
32
37
  var cachedSessions = [];
33
- var searchQuery = "";
34
- var searchMatchIds = null; // null = no search, Set of matched session IDs
35
- var searchDebounce = null;
36
38
  var expandedLoopGroups = new Set();
37
39
  var expandedLoopRuns = new Set();
38
40
 
39
- // --- Session presence (multi-user: who is viewing which session) ---
40
- var sessionPresence = {}; // { sessionId: [{ id, displayName, avatarStyle, avatarSeed }] }
41
-
42
41
  // --- Countdown timer for upcoming schedules ---
43
42
  var countdownTimer = null;
44
43
  var countdownContainer = null;
@@ -47,146 +46,17 @@ var countdownContainer = null;
47
46
  var sessionCtxMenu = null;
48
47
  var sessionCtxMenuAnchor = null;
49
48
  var sessionCtxSessionId = null;
50
- var draggedSessionId = null;
51
- var draggedSessionBookmarked = false;
52
- var headerSearchOpen = false;
53
49
  var armedDeleteSessionId = null;
54
50
  var armedDeleteTimer = null;
55
51
 
56
52
  store.subscribe(function (state, previous) {
57
- if (state.defaultVendorState !== previous.defaultVendorState || state.installedVendors !== previous.installedVendors) {
58
- var reopenNewMenu = !!(sessionCtxMenu && sessionCtxMenuAnchor);
59
- var sessionList = getSessionListEl();
60
- if (reopenNewMenu) closeSessionCtxMenu();
61
- if (sessionList) renderSessionList();
62
- if (reopenNewMenu && sessionList) {
63
- var freshAnchor = sessionList.querySelector(".session-top-action.split-provider");
64
- if (freshAnchor) showNewSessionMenu(freshAnchor, resolveDefaultVendor());
65
- }
66
- }
53
+ if ((state.sessionFolders !== previous.sessionFolders || folderToolbarNeedsRender(state, previous)) && getSessionListEl()) renderSessionList(null);
67
54
  });
68
55
 
69
- function sendSessionBookmark(sessionId, bookmarked) {
70
- if (getWs() && store.get('connected')) {
71
- getWs().send(JSON.stringify({ type: "set_session_bookmark", sessionId: sessionId, bookmarked: !!bookmarked }));
72
- }
73
- }
74
-
75
- function compareSessionListItems(a, b) {
76
- var aData = a && (a.type === "session" || a.type === "driver-hierarchy") ? a.data : a;
77
- var bData = b && (b.type === "session" || b.type === "driver-hierarchy") ? b.data : b;
78
- var aBookmarked = !!(aData && aData.bookmarked);
79
- var bBookmarked = !!(bData && bData.bookmarked);
80
- if (aBookmarked !== bBookmarked) return aBookmarked ? -1 : 1;
81
- if (aBookmarked && bBookmarked) {
82
- var ao = aData && typeof aData.favoriteOrder === "number" ? aData.favoriteOrder : Number.MAX_SAFE_INTEGER;
83
- var bo = bData && typeof bData.favoriteOrder === "number" ? bData.favoriteOrder : Number.MAX_SAFE_INTEGER;
84
- if (ao !== bo) return ao - bo;
85
- }
86
- return (b.lastActivity || 0) - (a.lastActivity || 0);
87
- }
88
-
89
- function clearSessionDragIndicators() {
90
- var listEl = getSessionListEl();
91
- if (!listEl) return;
92
- var active = listEl.querySelectorAll(".session-favorites-divider.drag-hover, .session-regular-drop.drag-hover, .session-item.dragging");
93
- for (var i = 0; i < active.length; i++) {
94
- active[i].classList.remove("drag-hover", "dragging");
95
- }
96
- }
97
-
98
- function setupSessionDragHandlers(el, session) {
99
- el.setAttribute("draggable", "true");
100
-
101
- el.addEventListener("dragstart", function (e) {
102
- draggedSessionId = session.id;
103
- draggedSessionBookmarked = !!session.bookmarked;
104
- e.dataTransfer.effectAllowed = "move";
105
- e.dataTransfer.setData("text/plain", String(session.id));
106
-
107
- var ghost = document.createElement("div");
108
- ghost.textContent = session.title || "New Session";
109
- ghost.style.cssText = "position:fixed;left:-200px;top:-200px;max-width:220px;padding:8px 12px;border-radius:10px;" +
110
- "background:var(--sidebar-active);color:var(--text);font-size:13px;font-weight:600;pointer-events:none;z-index:-1;";
111
- document.body.appendChild(ghost);
112
- e.dataTransfer.setDragImage(ghost, 18, 18);
113
- setTimeout(function () { ghost.remove(); }, 0);
114
-
115
- setTimeout(function () { el.classList.add("dragging"); }, 0);
116
- });
117
-
118
- el.addEventListener("dragend", function () {
119
- clearSessionDragIndicators();
120
- draggedSessionId = null;
121
- draggedSessionBookmarked = false;
122
- });
123
-
124
- if (session.bookmarked) {
125
- el.addEventListener("dragover", function (e) {
126
- if (!draggedSessionId || draggedSessionId === session.id) return;
127
- e.preventDefault();
128
- e.dataTransfer.dropEffect = "move";
129
- var rect = el.getBoundingClientRect();
130
- var insertBefore = e.clientY < rect.top + rect.height / 2;
131
- el.classList.remove("drag-over-above", "drag-over-below");
132
- el.classList.add(insertBefore ? "drag-over-above" : "drag-over-below");
133
- });
134
-
135
- el.addEventListener("dragleave", function () {
136
- el.classList.remove("drag-over-above", "drag-over-below");
137
- });
138
-
139
- el.addEventListener("drop", function (e) {
140
- if (!draggedSessionId || draggedSessionId === session.id) return;
141
- e.preventDefault();
142
- var rect = el.getBoundingClientRect();
143
- var insertBefore = e.clientY < rect.top + rect.height / 2;
144
- el.classList.remove("drag-over-above", "drag-over-below");
145
- if (draggedSessionBookmarked) {
146
- if (getWs() && store.get('connected')) {
147
- getWs().send(JSON.stringify({
148
- type: "reorder_session_bookmarks",
149
- sourceId: draggedSessionId,
150
- targetId: session.id,
151
- insertBefore: insertBefore,
152
- }));
153
- }
154
- } else {
155
- sendSessionBookmark(draggedSessionId, true);
156
- }
157
- });
158
- }
159
- }
160
-
161
- function setupBookmarkDropTarget(el, bookmarked) {
162
- el.addEventListener("dragover", function (e) {
163
- if (!draggedSessionId) return;
164
- e.preventDefault();
165
- e.dataTransfer.dropEffect = "move";
166
- el.classList.add("drag-hover");
167
- });
168
-
169
- el.addEventListener("dragleave", function () {
170
- el.classList.remove("drag-hover");
171
- });
172
-
173
- el.addEventListener("drop", function (e) {
174
- if (!draggedSessionId) return;
175
- e.preventDefault();
176
- el.classList.remove("drag-hover");
177
- if (draggedSessionBookmarked !== !!bookmarked) {
178
- sendSessionBookmark(draggedSessionId, !!bookmarked);
179
- }
180
- clearSessionDragIndicators();
181
- draggedSessionId = null;
182
- draggedSessionBookmarked = false;
183
- });
184
- }
185
-
186
56
  function spawnSessionDeleteParticles(sessionId) {
187
57
  if (!spawnDustParticles) return;
188
58
  setTimeout(function () {
189
- var el = getSessionListEl().querySelector('[data-session-id="' + sessionId + '"]');
59
+ var el = visibleSessionRow(sessionId) || getSessionListEl().querySelector('[data-session-id="' + sessionId + '"]');
190
60
  if (!el) return;
191
61
  var rect = el.getBoundingClientRect();
192
62
  spawnDustParticles(rect.left + rect.width / 2, rect.top + rect.height / 2);
@@ -209,14 +79,15 @@ function clearArmedSessionDelete() {
209
79
  armedDeleteTimer = null;
210
80
  }
211
81
  if (armedDeleteSessionId !== null) {
212
- var prevBtn = getSessionListEl() ? getSessionListEl().querySelector('.session-close-btn[data-session-id="' + armedDeleteSessionId + '"]') : null;
213
- if (prevBtn) {
214
- prevBtn.classList.remove("armed");
215
- prevBtn.innerHTML = iconHtml("x");
216
- prevBtn.title = "Delete session";
217
- prevBtn.setAttribute("aria-label", "Delete session");
218
- refreshIcons();
82
+ // A session can appear twice (its folder and Favorites): reset every copy.
83
+ var prevBtns = getSessionListEl() ? getSessionListEl().querySelectorAll('.session-close-btn[data-session-id="' + armedDeleteSessionId + '"]') : [];
84
+ for (var pb = 0; pb < prevBtns.length; pb++) {
85
+ prevBtns[pb].classList.remove("armed");
86
+ prevBtns[pb].innerHTML = iconHtml("x");
87
+ prevBtns[pb].title = "Delete session";
88
+ prevBtns[pb].setAttribute("aria-label", "Delete session");
219
89
  }
90
+ if (prevBtns.length) refreshIcons();
220
91
  }
221
92
  armedDeleteSessionId = null;
222
93
  }
@@ -242,70 +113,6 @@ function deleteSessionImmediately(session) {
242
113
  }
243
114
  }
244
115
 
245
- function collectItemSessionIds(item) {
246
- if (!item) return [];
247
- if (item.type === "session" && item.data && typeof item.data.id === "number") {
248
- if (!isSessionVisibleBySearch(item.data.id)) return [];
249
- return [item.data.id];
250
- }
251
- if (item.type === "loop" && Array.isArray(item.children)) {
252
- var ids = [];
253
- for (var i = 0; i < item.children.length; i++) {
254
- if (typeof item.children[i].id === "number" && isSessionVisibleBySearch(item.children[i].id)) {
255
- ids.push(item.children[i].id);
256
- }
257
- }
258
- return ids;
259
- }
260
- if (item.type === "split-group" && Array.isArray(item.members)) {
261
- return item.members.map(function (session) { return session.id; });
262
- }
263
- if (item.type === "driver-hierarchy" && item.root) {
264
- var hierarchyIds = [item.root.driver.id];
265
- for (var j = 0; j < item.root.workers.length; j++) hierarchyIds.push(item.root.workers[j].id);
266
- return hierarchyIds;
267
- }
268
- if (item.type === "orphan-workers" && Array.isArray(item.workers)) {
269
- return item.workers.map(function (session) { return session.id; });
270
- }
271
- return [];
272
- }
273
-
274
- function confirmDeleteSessionGroup(groupLabel, sessionIds) {
275
- if (!Array.isArray(sessionIds) || sessionIds.length === 0) return;
276
- showConfirm(describeGroupDelete(groupLabel, sessionIds, cachedSessions).message, function () {
277
- var ws = getWs();
278
- if (ws && store.get('connected')) {
279
- ws.send(JSON.stringify({ type: "bulk_delete_sessions", sessionIds: sessionIds }));
280
- }
281
- });
282
- }
283
-
284
- function createSessionGroupHeader(group, sessionIds) {
285
- var header = document.createElement("div");
286
- header.className = "session-group-header";
287
-
288
- var label = document.createElement("span");
289
- label.className = "session-group-header-label";
290
- label.textContent = group;
291
- header.appendChild(label);
292
-
293
- if ((!store.get('permissions') || store.get('permissions').sessionDelete !== false) && Array.isArray(sessionIds) && sessionIds.length > 0) {
294
- var clearBtn = document.createElement("button");
295
- clearBtn.className = "session-group-clear-btn";
296
- clearBtn.type = "button";
297
- clearBtn.textContent = "Clear";
298
- clearBtn.addEventListener("click", function (e) {
299
- e.preventDefault();
300
- e.stopPropagation();
301
- confirmDeleteSessionGroup(group, sessionIds);
302
- });
303
- header.appendChild(clearBtn);
304
- }
305
-
306
- return header;
307
- }
308
-
309
116
  function appendSessionCloseButton(el, session) {
310
117
  if (store.get('permissions') && store.get('permissions').sessionDelete === false) return;
311
118
 
@@ -348,13 +155,22 @@ export function resolveDefaultVendor() {
348
155
  // Otherwise vendorSelectionLocked from the previous session can reject the
349
156
  // new config_state and leave a Kiro/Codex session rendered as Claude.
350
157
  export function startNewSession(vendor, extra) {
351
- if (!getWs() || !store.get('connected')) return;
158
+ if (!getWs() || !store.get('connected')) return false;
352
159
  var payload = { type: "new_session", vendor: vendor };
353
160
  if (extra) {
354
161
  var keys = Object.keys(extra);
355
162
  for (var i = 0; i < keys.length; i++) payload[keys[i]] = extra[keys[i]];
356
163
  }
357
- getWs().send(JSON.stringify(payload));
164
+ try {
165
+ getWs().send(JSON.stringify(payload));
166
+ } catch (e) {
167
+ return false;
168
+ }
169
+ // The destination (Unfiled unless a folder was chosen) is expanded and the
170
+ // new session revealed once it becomes active.
171
+ // A creation that carries a request id is revealed from its acknowledgement,
172
+ // which knows where the session was really filed.
173
+ if (!(extra && extra.requestId)) expectCreatedSession(extra && extra.folderId ? extra.folderId : "unfiled");
358
174
  store.set({
359
175
  lastVendor: vendor,
360
176
  currentVendor: vendor,
@@ -363,273 +179,9 @@ export function startNewSession(vendor, extra) {
363
179
  vendorSelectionLocked: true,
364
180
  sessionVendorBound: true,
365
181
  });
182
+ return true;
366
183
  }
367
184
 
368
- export function renderSessionTopActions() {
369
- var wrap = document.createElement("div");
370
- wrap.className = "session-top-actions";
371
-
372
- var defaultVendor = resolveDefaultVendor();
373
- var vendorName = VENDOR_NAMES[defaultVendor] || defaultVendor;
374
- var createTitle = "Create a " + vendorName + " session";
375
- if (defaultVendorUnavailable(store.get('installedVendors') || [])) createTitle += " (saved default is unavailable; using installed priority)";
376
- var controls;
377
- controls = renderSessionCreationCta({
378
- rowClass: "session-top-action-split",
379
- createClass: "session-top-action split-main",
380
- providerClass: "session-top-action split-provider",
381
- iconClass: "session-top-action-icon",
382
- vendorKey: defaultVendor,
383
- vendorName: vendorName,
384
- vendorAvatar: VENDOR_AVATARS[defaultVendor] || VENDOR_AVATARS.claude,
385
- createTitle: createTitle,
386
- providerTitle: "Switch AI provider (current: " + vendorName + ")",
387
- menuId: "session-new-menu",
388
- onCreate: function () { startNewSession(defaultVendor); },
389
- onProvider: function (e) {
390
- e.preventDefault();
391
- e.stopPropagation();
392
- if (sessionCtxMenu) { closeSessionCtxMenu(); return; }
393
- showNewSessionMenu(controls.providerButton, defaultVendor);
394
- },
395
- });
396
- wrap.appendChild(controls.element);
397
- return wrap;
398
- }
399
-
400
- // Provider disclosure anchored to the grouped provider control. Reuses the
401
- // session-ctx-menu element/var so the global document click handler closes it.
402
- function showNewSessionMenu(anchorBtn, defaultVendor) {
403
- closeSessionCtxMenu();
404
-
405
- var menu = document.createElement("div");
406
- menu.className = "session-ctx-menu session-new-menu";
407
- menu.id = "session-new-menu";
408
- var installed = store.get('installedVendors') || [];
409
- var defaultVendorState = store.get('defaultVendorState') || {};
410
- sessionCtxMenuAnchor = anchorBtn;
411
- anchorBtn.setAttribute("aria-expanded", "true");
412
-
413
- if (defaultVendorUnavailable(installed) && defaultVendorState.preference) {
414
- var fallbackNote = document.createElement("div");
415
- fallbackNote.className = "session-new-preference-status session-new-fallback-note";
416
- fallbackNote.textContent = "Saved default " + (VENDOR_NAMES[defaultVendorState.preference] || defaultVendorState.preference) + " is unavailable. Using " + (VENDOR_NAMES[defaultVendor] || defaultVendor) + ".";
417
- menu.appendChild(fallbackNote);
418
- }
419
-
420
- // Every known vendor is listed. Uninstalled ones stay visible but disabled,
421
- // and clicking one opens the vendor's homepage so the user can install it.
422
- for (var i = 0; i < VENDOR_ORDER.length; i++) {
423
- (function (vendor) {
424
- var isInstalled = installed.indexOf(vendor) !== -1;
425
- var name = VENDOR_NAMES[vendor] || vendor;
426
- var row = document.createElement("div");
427
- row.className = "session-new-vendor-row";
428
- var item = document.createElement("button");
429
- item.className = "session-ctx-item session-new-vendor";
430
- if (!isInstalled) item.classList.add("disabled");
431
- if (vendor === defaultVendor) item.classList.add("active");
432
- item.type = "button";
433
- item.innerHTML = '<img src="' + VENDOR_AVATARS[vendor] + '" class="session-top-action-icon" alt="">' +
434
- '<span class="session-new-vendor-name">' + (isInstalled ? "Create with " : "Learn about ") + name + '</span>' +
435
- (isExperimentalVendor(vendor) ? '<span class="vendor-experimental-badge" aria-label="Experimental integration" title="Experimental integration; not yet validated through direct use testing">🧪</span>' : '') +
436
- (isInstalled ? "" : '<span class="session-new-vendor-note">Not installed</span>' + iconHtml("external-link"));
437
- item.title = isInstalled
438
- ? "New " + name + " session"
439
- : name + " is not installed - open " + VENDOR_HOMEPAGES[vendor];
440
- if (isExperimentalVendor(vendor)) item.title += " · Experimental integration; not yet validated through direct use testing";
441
- item.addEventListener("click", function (e) {
442
- e.stopPropagation();
443
- closeSessionCtxMenu();
444
- if (!isInstalled) {
445
- window.open(VENDOR_HOMEPAGES[vendor], "_blank", "noopener");
446
- return;
447
- }
448
- startNewSession(vendor);
449
- });
450
- row.appendChild(item);
451
- if (isInstalled) {
452
- var defaultBtn = document.createElement("button");
453
- defaultBtn.type = "button";
454
- defaultBtn.className = "session-new-set-default";
455
- defaultBtn.textContent = defaultVendorState.saving ? "Saving…" : (vendor === defaultVendor && !defaultVendorUnavailable(installed) ? "Default" : "Set default");
456
- defaultBtn.disabled = defaultVendorState.saving === true;
457
- defaultBtn.setAttribute("aria-label", "Set " + name + " as default vendor");
458
- defaultBtn.title = "Use " + name + " for New session by default";
459
- defaultBtn.addEventListener("click", function (e) {
460
- e.preventDefault();
461
- e.stopPropagation();
462
- saveDefaultVendor(vendor);
463
- });
464
- row.appendChild(defaultBtn);
465
- }
466
- menu.appendChild(row);
467
- })(VENDOR_ORDER[i]);
468
- }
469
-
470
- var automatic = document.createElement("button");
471
- automatic.className = "session-ctx-item session-new-automatic";
472
- automatic.type = "button";
473
- automatic.textContent = "Use automatic default";
474
- automatic.disabled = defaultVendorState.saving === true;
475
- automatic.title = "Use Clay's installed-vendor priority";
476
- automatic.addEventListener("click", function (e) {
477
- e.stopPropagation();
478
- closeSessionCtxMenu();
479
- saveDefaultVendor("");
480
- });
481
- menu.appendChild(automatic);
482
- if (defaultVendorState.error) {
483
- var status = document.createElement("div");
484
- status.className = "session-new-preference-status";
485
- status.setAttribute("role", "alert");
486
- status.textContent = defaultVendorState.error;
487
- menu.appendChild(status);
488
- }
489
-
490
- if (installed.length > 0) {
491
- var pairSep = document.createElement("div");
492
- pairSep.className = "session-ctx-sep";
493
- menu.appendChild(pairSep);
494
- var pairItem = document.createElement("button");
495
- pairItem.className = "session-ctx-item";
496
- pairItem.type = "button";
497
- pairItem.innerHTML = iconHtml("panel-left-close") + " <span>Pair session…</span>";
498
- pairItem.title = "Create a Driver and Worker in a split view";
499
- pairItem.addEventListener("click", function (e) {
500
- e.stopPropagation();
501
- closeSessionCtxMenu();
502
- openPairDialog();
503
- });
504
- menu.appendChild(pairItem);
505
- }
506
-
507
- // Claude-only launch mode: a bare TUI shell with permission prompts off.
508
- // Hidden in GUI mode (the default) because the item only ever produces a
509
- // terminal session, which contradicts what the rest of the menu creates.
510
- if (installed.indexOf("claude") !== -1 && store.get('claudeOpenMode') === "tui") {
511
- var sep = document.createElement("div");
512
- sep.className = "session-ctx-sep";
513
- menu.appendChild(sep);
514
-
515
- var skipItem = document.createElement("button");
516
- skipItem.className = "session-ctx-item";
517
- skipItem.type = "button";
518
- skipItem.innerHTML = iconHtml("shield-off") + " <span>Skip Permissions (TUI)</span>";
519
- skipItem.title = "Start a terminal session with --dangerously-skip-permissions";
520
- skipItem.addEventListener("click", function (e) {
521
- e.stopPropagation();
522
- closeSessionCtxMenu();
523
- startNewSession("claude", { mode: "tui", dangerouslySkipPermissions: true });
524
- });
525
- menu.appendChild(skipItem);
526
- }
527
-
528
- document.body.appendChild(menu);
529
- sessionCtxMenu = menu;
530
- menu.addEventListener("keydown", function (e) {
531
- if (e.key !== "Escape") return;
532
- e.preventDefault();
533
- e.stopPropagation();
534
- closeSessionCtxMenu(true);
535
- });
536
- refreshIcons();
537
-
538
- requestAnimationFrame(function () {
539
- var btnRect = anchorBtn.getBoundingClientRect();
540
- menu.style.position = "fixed";
541
- menu.style.top = (btnRect.bottom + 2) + "px";
542
- menu.style.left = btnRect.left + "px";
543
- menu.style.right = "auto";
544
- var menuRect = menu.getBoundingClientRect();
545
- if (menuRect.right > window.innerWidth - 8) {
546
- menu.style.left = "auto";
547
- menu.style.right = (window.innerWidth - btnRect.right) + "px";
548
- }
549
- if (menuRect.bottom > window.innerHeight - 8) {
550
- menu.style.top = (btnRect.top - menuRect.height - 2) + "px";
551
- }
552
- var firstMenuItem = menu.querySelector("button:not(:disabled)");
553
- if (firstMenuItem) firstMenuItem.focus();
554
- });
555
- }
556
-
557
- function runSessionSearch(query) {
558
- var normalizedQuery = query || "";
559
- var trimmedQuery = normalizedQuery.trim();
560
- searchQuery = normalizedQuery;
561
- if (searchDebounce) {
562
- clearTimeout(searchDebounce);
563
- searchDebounce = null;
564
- }
565
- if (!trimmedQuery) {
566
- searchMatchIds = null;
567
- renderSessionList(null);
568
- return;
569
- }
570
- searchDebounce = setTimeout(function () {
571
- if (getWs() && store.get('connected')) {
572
- getWs().send(JSON.stringify({ type: "search_sessions", query: searchQuery }));
573
- }
574
- }, 200);
575
- }
576
-
577
- function syncHeaderSearchUi() {
578
- var searchInline = document.getElementById("session-header-search-inline");
579
- var searchInput = document.getElementById("session-header-search-input");
580
- var searchClear = document.getElementById("session-header-search-clear");
581
- var searchBtn = document.getElementById("session-header-search-btn");
582
- var filterCount = document.getElementById("session-filter-count");
583
- var isOpen = headerSearchOpen || !!searchQuery;
584
- if (!searchInline || !searchInput || !searchClear || !searchBtn || !filterCount) return;
585
- searchInline.classList.toggle("hidden", !isOpen);
586
- searchBtn.classList.toggle("active", isOpen);
587
- if (searchInput.value !== searchQuery) {
588
- searchInput.value = searchQuery;
589
- }
590
- searchClear.classList.toggle("hidden", !searchQuery);
591
- if (!searchQuery || searchMatchIds === null) {
592
- filterCount.classList.add("hidden");
593
- filterCount.textContent = "";
594
- } else {
595
- filterCount.classList.remove("hidden");
596
- filterCount.textContent = String(searchMatchIds.size);
597
- }
598
- }
599
-
600
- function openHeaderSearch() {
601
- headerSearchOpen = true;
602
- syncHeaderSearchUi();
603
- var searchInput = document.getElementById("session-header-search-input");
604
- if (searchInput) {
605
- requestAnimationFrame(function () {
606
- searchInput.focus();
607
- searchInput.select();
608
- });
609
- }
610
- }
611
-
612
- function closeHeaderSearch() {
613
- headerSearchOpen = false;
614
- syncHeaderSearchUi();
615
- }
616
-
617
- function clearSessionSearch(shouldBlur, input, shouldClose) {
618
- if (searchDebounce) {
619
- clearTimeout(searchDebounce);
620
- searchDebounce = null;
621
- }
622
- searchQuery = "";
623
- searchMatchIds = null;
624
- if (shouldClose) {
625
- headerSearchOpen = false;
626
- }
627
- syncHeaderSearchUi();
628
- renderSessionList(null);
629
- if (shouldBlur && input) {
630
- input.blur();
631
- }
632
- }
633
185
 
634
186
  export function initSidebarSessions() {
635
187
 
@@ -638,63 +190,12 @@ export function initSidebarSessions() {
638
190
  clearArmedSessionDelete();
639
191
  });
640
192
 
641
- var searchBtn = document.getElementById("session-header-search-btn");
642
- var searchInput = document.getElementById("session-header-search-input");
643
- var searchClear = document.getElementById("session-header-search-clear");
644
- var searchInline = document.getElementById("session-header-search-inline");
645
-
646
- if (searchBtn && searchInput && searchClear && searchInline) {
647
- searchBtn.addEventListener("click", function () {
648
- if (!headerSearchOpen && !searchQuery) {
649
- openHeaderSearch();
650
- return;
651
- }
652
- if (!searchQuery) {
653
- closeHeaderSearch();
654
- return;
655
- }
656
- searchInput.focus();
657
- searchInput.select();
658
- });
659
-
660
- searchInput.addEventListener("input", function () {
661
- runSessionSearch(searchInput.value);
662
- syncHeaderSearchUi();
663
- });
664
-
665
- searchInput.addEventListener("keydown", function (e) {
666
- if (e.key === "Escape") {
667
- e.preventDefault();
668
- if (searchInput.value.trim()) {
669
- clearSessionSearch(false, searchInput, false);
670
- return;
671
- }
672
- clearSessionSearch(true, searchInput, true);
673
- }
674
- });
675
-
676
- searchInput.addEventListener("blur", function () {
677
- setTimeout(function () {
678
- if (!searchQuery && document.activeElement !== searchBtn && document.activeElement !== searchClear) {
679
- closeHeaderSearch();
680
- }
681
- }, 0);
682
- });
683
-
684
- searchClear.addEventListener("click", function () {
685
- clearSessionSearch(false, searchInput, false);
686
- searchInput.focus();
687
- });
688
-
689
- syncHeaderSearchUi();
690
- }
691
-
692
- // The "New session" split button renders from lastVendor + installedVendors,
693
- // so a late-arriving vendor detection or a vendor switch has to repaint it.
193
+ // Folder form, folder state and the creation reveal repaint through their own
194
+ // subscriptions; only structural changes to the open form need a list render.
694
195
  store.subscribe(function (state, prev) {
695
- if (state.lastVendor !== prev.lastVendor || state.installedVendors !== prev.installedVendors) {
696
- renderSessionList(null);
697
- }
196
+ var a = state.sessionCreate;
197
+ var b = prev.sessionCreate;
198
+ if ((!a !== !b || (a && b && a.folderId !== b.folderId)) && getSessionListEl()) renderSessionList(null);
698
199
  if (state.splitPanes !== prev.splitPanes) renderSessionList(null);
699
200
  });
700
201
 
@@ -710,11 +211,11 @@ export function getCachedSessions() {
710
211
  }
711
212
 
712
213
  export function getSearchQuery() {
713
- return searchQuery;
214
+ return getListSearchQuery();
714
215
  }
715
216
 
716
217
  export function getSearchMatchIds() {
717
- return searchMatchIds;
218
+ return getListSearchMatchIds();
718
219
  }
719
220
 
720
221
  export function getExpandedLoopGroups() {
@@ -746,15 +247,28 @@ function showSessionCtxMenu(anchorBtn, sessionId, title, cliSid, sessionData) {
746
247
  var menu = document.createElement("div");
747
248
  menu.className = "session-ctx-menu";
748
249
 
749
- var bookmarkItem = document.createElement("button");
750
- bookmarkItem.className = "session-ctx-item";
751
- bookmarkItem.innerHTML = iconHtml(sessionData && sessionData.bookmarked ? "arrow-down" : "arrow-up") + " <span>" + (sessionData && sessionData.bookmarked ? "Remove from Favorites" : "Add to Favorites") + "</span>";
752
- bookmarkItem.addEventListener("click", function (e) {
753
- e.stopPropagation();
754
- closeSessionCtxMenu();
755
- sendSessionBookmark(sessionId, !(sessionData && sessionData.bookmarked));
756
- });
757
- menu.appendChild(bookmarkItem);
250
+ if (canOrganizeSession(sessionData)) {
251
+ var isFavorite = isFavoriteSession(sessionData);
252
+ var bookmarkItem = document.createElement("button");
253
+ bookmarkItem.className = "session-ctx-item";
254
+ bookmarkItem.innerHTML = iconHtml(isFavorite ? "star-off" : "star") + " <span>" + (isFavorite ? "Remove from Favorites" : "Add to Favorites") + "</span>";
255
+ bookmarkItem.addEventListener("click", function (e) {
256
+ e.stopPropagation();
257
+ closeSessionCtxMenu();
258
+ toggleFavorite(sessionData);
259
+ });
260
+ menu.appendChild(bookmarkItem);
261
+
262
+ var moveItem = document.createElement("button");
263
+ moveItem.className = "session-ctx-item";
264
+ moveItem.innerHTML = iconHtml("folder-input") + " <span>Move to folder</span>";
265
+ moveItem.addEventListener("click", function (e) {
266
+ e.stopPropagation();
267
+ closeSessionCtxMenu();
268
+ openMoveToFolder(sessionData);
269
+ });
270
+ menu.appendChild(moveItem);
271
+ }
758
272
 
759
273
  var renameItem = document.createElement("button");
760
274
  renameItem.className = "session-ctx-item";
@@ -816,6 +330,10 @@ function showSessionCtxMenu(anchorBtn, sessionId, title, cliSid, sessionData) {
816
330
  });
817
331
  }
818
332
 
333
+ export function openSessionContextMenu(anchor, session) {
334
+ showSessionCtxMenu(anchor, session.id, session.title, session.cliSessionId, session);
335
+ }
336
+
819
337
  function showSplitGroupCtxMenu(anchor, group) {
820
338
  closeSessionCtxMenu();
821
339
  var menu = document.createElement("div");
@@ -909,8 +427,16 @@ function showLoopCtxMenu(anchorBtn, loopId, loopName, childCount) {
909
427
 
910
428
  // --- Inline rename ---
911
429
 
430
+ // A session can be on screen twice (its folder and Favorites); work on the copy
431
+ // the user can actually see.
432
+ function visibleSessionRow(sessionId) {
433
+ var rows = getSessionListEl().querySelectorAll('.session-item[data-session-id="' + sessionId + '"]');
434
+ for (var i = 0; i < rows.length; i++) if (rows[i].getClientRects().length) return rows[i];
435
+ return rows[0] || null;
436
+ }
437
+
912
438
  function startInlineRename(sessionId, currentTitle) {
913
- var el = getSessionListEl().querySelector('.session-item[data-session-id="' + sessionId + '"]');
439
+ var el = visibleSessionRow(sessionId);
914
440
  if (!el) return;
915
441
  var textSpan = el.querySelector(".session-item-text");
916
442
  if (!textSpan) return;
@@ -1018,19 +544,7 @@ function startLoopInlineRename(loopId, currentName) {
1018
544
  input.addEventListener("click", function (e) { e.stopPropagation(); });
1019
545
  }
1020
546
 
1021
- // --- Date grouping / highlighting ---
1022
-
1023
- export function getDateGroup(ts) {
1024
- var now = new Date();
1025
- var d = new Date(ts);
1026
- var today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
1027
- var yesterday = new Date(today.getTime() - 86400000);
1028
- var weekAgo = new Date(today.getTime() - 7 * 86400000);
1029
- if (d >= today) return "Today";
1030
- if (d >= yesterday) return "Yesterday";
1031
- if (d >= weekAgo) return "This Week";
1032
- return "Older";
1033
- }
547
+ // --- Highlighting ---
1034
548
 
1035
549
  export function highlightMatch(text, query) {
1036
550
  if (!query) return escapeHtml(text);
@@ -1045,15 +559,15 @@ export function highlightMatch(text, query) {
1045
559
  }
1046
560
 
1047
561
  function isSessionVisibleBySearch(sessionId) {
1048
- if (searchMatchIds === null) return true;
1049
- return searchMatchIds.has(sessionId);
562
+ if (getListSearchMatchIds() === null) return true;
563
+ return getListSearchMatchIds().has(sessionId);
1050
564
  }
1051
565
 
1052
566
  // --- Loop child / run / group rendering ---
1053
567
 
1054
568
  function renderLoopChild(s) {
1055
569
  var el = document.createElement("div");
1056
- var isMatch = searchMatchIds !== null && searchMatchIds.has(s.id);
570
+ var isMatch = getListSearchMatchIds() !== null && getListSearchMatchIds().has(s.id);
1057
571
  el.className = "session-loop-child" + (s.active ? " active" : "") + (isMatch ? " search-match" : "");
1058
572
  el.dataset.sessionId = s.id;
1059
573
 
@@ -1092,7 +606,7 @@ function renderLoopChild(s) {
1092
606
 
1093
607
  function renderLoopGroup(loopId, children, groupKey) {
1094
608
  var visibleChildren = children;
1095
- if (searchMatchIds !== null) {
609
+ if (getListSearchMatchIds() !== null) {
1096
610
  visibleChildren = [];
1097
611
  for (var vi = 0; vi < children.length; vi++) {
1098
612
  if (isSessionVisibleBySearch(children[vi].id)) {
@@ -1335,7 +849,7 @@ function renderLoopRun(parentGk, startedAtKey, sessions, isRalph) {
1335
849
  function renderSessionItem(s, options) {
1336
850
  var itemOptions = options || {};
1337
851
  var el = document.createElement("div");
1338
- var isMatch = searchMatchIds !== null && searchMatchIds.has(s.id);
852
+ var isMatch = getListSearchMatchIds() !== null && getListSearchMatchIds().has(s.id);
1339
853
  el.className = "session-item" + (s.active ? " active" : "") + (isMatch ? " search-match" : "");
1340
854
  if (itemOptions.worker) el.classList.add("session-worker-item");
1341
855
  el.dataset.sessionId = s.id;
@@ -1365,7 +879,7 @@ function renderSessionItem(s, options) {
1365
879
  if (store.get('isMultiUserMode') && s.sessionVisibility === "shared") {
1366
880
  textHtml += '<span class="session-shared-icon" title="Shared session" aria-label="Shared session">' + iconHtml("users") + '</span>';
1367
881
  }
1368
- textHtml += highlightMatch(s.title || "New Session", searchQuery);
882
+ textHtml += highlightMatch(s.title || "New Session", getListSearchQuery());
1369
883
  textSpan.innerHTML = textHtml;
1370
884
  el.appendChild(textSpan);
1371
885
  appendGithubWork(el, s, textSpan);
@@ -1403,12 +917,19 @@ function renderSessionItem(s, options) {
1403
917
  unreadBadge.classList.add("has-unread");
1404
918
  }
1405
919
  el.appendChild(unreadBadge);
1406
- appendSessionCloseButton(el, s);
920
+ var actions = document.createElement("span");
921
+ actions.className = "session-row-actions";
922
+ if (!itemOptions.worker && canOrganizeSession(s)) {
923
+ actions.appendChild(createStarButton(s));
924
+ if (isFavoriteSession(s)) el.classList.add("is-favorite");
925
+ }
926
+ appendSessionCloseButton(actions, s);
927
+ if (actions.childNodes.length) el.appendChild(actions);
1407
928
 
1408
929
  el.addEventListener("click", (function (id) {
1409
930
  return function () {
1410
931
  if (getWs() && store.get('connected')) {
1411
- var pendingQuery = searchQuery || "";
932
+ var pendingQuery = getListSearchQuery() || "";
1412
933
  getWs().send(JSON.stringify({ type: "switch_session", id: id }));
1413
934
  dismissOverlayPanels();
1414
935
  closeSidebar();
@@ -1420,8 +941,8 @@ function renderSessionItem(s, options) {
1420
941
  })(s.id));
1421
942
 
1422
943
  // Presence avatars (multi-user)
1423
- renderPresenceAvatars(el, String(s.id));
1424
- if (!itemOptions.worker) setupSessionDragHandlers(el, s);
944
+ renderSessionPresence(el, String(s.id));
945
+ if (!itemOptions.worker) setupFolderDrag(el, s);
1425
946
 
1426
947
  return el;
1427
948
  }
@@ -1450,7 +971,7 @@ function renderSplitGroupItem(group, members) {
1450
971
  }
1451
972
  if (splitProcessing) html += '<span class="session-processing"></span>';
1452
973
  if (splitBackgroundTaskCount) html += '<span class="session-background-task-count">' + splitBackgroundTaskCount + '</span>';
1453
- html += '<span class="split-group-name">' + highlightMatch(group.name || "Split", searchQuery) + "</span>";
974
+ html += '<span class="split-group-name">' + highlightMatch(group.name || "Split", getListSearchQuery()) + "</span>";
1454
975
  text.innerHTML = html;
1455
976
  el.appendChild(text);
1456
977
 
@@ -1502,6 +1023,11 @@ export function renderSessionList(sessions) {
1502
1023
  cachedSessions = sessions;
1503
1024
  }
1504
1025
 
1026
+ // Remember the inline folder draft before either surface is rebuilt: the mobile
1027
+ // refresh below builds a fresh row whose queued focus must not be re-captured.
1028
+ captureFolderInputs();
1029
+ captureFolderCollapseAnimations(document);
1030
+
1505
1031
  // If mobile chat sheet is open, refresh it
1506
1032
  if (refreshMobileChatSheet) refreshMobileChatSheet();
1507
1033
 
@@ -1584,94 +1110,37 @@ export function renderSessionList(sessions) {
1584
1110
  items.push({ type: "loop", loopId: realLoopId, groupKey: gk, children: children, lastActivity: maxActivity });
1585
1111
  }
1586
1112
 
1587
- // Sort by lastActivity descending
1588
- items.sort(compareSessionListItems);
1589
-
1590
- var bookmarkedItems = [];
1591
- var regularItems = [];
1592
- for (var n = 0; n < items.length; n++) {
1593
- var item = items[n];
1594
- if (item.type === "session" && item.data && !isSessionVisibleBySearch(item.data.id)) {
1595
- continue;
1596
- }
1597
- if ((item.type === "driver-hierarchy" || item.type === "orphan-workers") && !hierarchyItemMatches(item, searchMatchIds)) continue;
1598
- if (item.type === "split-group" && searchMatchIds !== null) {
1599
- var splitSearchMatch = false;
1600
- for (var splitSearchIndex = 0; splitSearchIndex < item.members.length; splitSearchIndex++) {
1601
- if (searchMatchIds.has(item.members[splitSearchIndex].id)) splitSearchMatch = true;
1602
- }
1603
- if (!splitSearchMatch) continue;
1604
- }
1605
- if ((item.type === "session" || item.type === "driver-hierarchy") && item.data && item.data.bookmarked) {
1606
- bookmarkedItems.push(item);
1607
- } else {
1608
- regularItems.push(item);
1609
- }
1610
- }
1611
-
1612
- var favoritesContainer = document.createElement("div");
1613
- favoritesContainer.className = "session-favorites-section";
1614
- setupBookmarkDropTarget(favoritesContainer, true);
1615
- if (bookmarkedItems.length === 0) {
1616
- var emptyHint = document.createElement("div");
1617
- emptyHint.className = "session-favorites-empty";
1618
- emptyHint.textContent = "Drag and drop sessions here to add favorites.";
1619
- favoritesContainer.appendChild(emptyHint);
1620
- }
1621
- for (var bi = 0; bi < bookmarkedItems.length; bi++) {
1622
- if (bookmarkedItems[bi].type === "driver-hierarchy") favoritesContainer.appendChild(renderDesktopDriverHierarchy(bookmarkedItems[bi].root, renderSessionItem, function () { renderSessionList(null); }, searchMatchIds));
1623
- else favoritesContainer.appendChild(renderSessionItem(bookmarkedItems[bi].data));
1624
- }
1113
+ var matchIds = getListSearchMatchIds();
1114
+ var visibleItems = filterItemsBySearch(items, matchIds);
1115
+
1116
+ var rerender = function () { renderSessionList(null); };
1117
+ var layout = computeLayout(visibleItems, matchIds !== null, "desktop");
1118
+ var folderCtx = {
1119
+ surface: "desktop",
1120
+ searching: matchIds !== null,
1121
+ renderItem: function (it) {
1122
+ if (it.type === "loop") return renderLoopGroup(it.loopId, it.children, it.groupKey) || document.createElement("div");
1123
+ if (it.type === "split-group") return renderSplitGroupItem(it.group, it.members);
1124
+ if (it.type === "driver-hierarchy") return renderDesktopDriverHierarchy(it.root, renderSessionItem, rerender, matchIds);
1125
+ if (it.type === "orphan-workers") return renderDesktopOrphanHierarchy(it.workers, renderSessionItem, rerender, matchIds);
1126
+ return renderSessionItem(it.data);
1127
+ },
1128
+ };
1625
1129
 
1626
- var divider = document.createElement("div");
1627
- divider.className = "session-favorites-divider";
1130
+ // The folder toolbar sits above one continuous list: Favorites first, then
1131
+ // custom folders, then Unfiled. Each header has its own New session button.
1132
+ var topActions = document.createElement("div");
1133
+ topActions.className = "session-list-sticky-top";
1134
+ topActions.appendChild(renderFolderToolbar("desktop"));
1135
+ getSessionListEl().appendChild(topActions);
1628
1136
 
1629
1137
  var regularContainer = document.createElement("div");
1630
1138
  regularContainer.className = "session-regular-drop";
1631
- setupBookmarkDropTarget(regularContainer, false);
1632
- var stickyTop = document.createElement("div");
1633
- stickyTop.className = "session-list-sticky-top";
1634
- stickyTop.appendChild(favoritesContainer);
1635
- stickyTop.appendChild(divider);
1636
- stickyTop.appendChild(renderSessionTopActions());
1637
- getSessionListEl().appendChild(stickyTop);
1638
-
1639
- var currentGroup = "";
1640
- var currentGroupIds = [];
1641
- for (var ri = 0; ri < regularItems.length; ri++) {
1642
- var item = regularItems[ri];
1643
- var group = getDateGroup(item.lastActivity || 0);
1644
- if (group !== currentGroup) {
1645
- currentGroup = group;
1646
- currentGroupIds = [];
1647
- for (var gi = ri; gi < regularItems.length; gi++) {
1648
- if (getDateGroup(regularItems[gi].lastActivity || 0) !== group) break;
1649
- var groupIds = collectItemSessionIds(regularItems[gi]);
1650
- for (var gj = 0; gj < groupIds.length; gj++) currentGroupIds.push(groupIds[gj]);
1651
- }
1652
- if (group !== "Today") {
1653
- regularContainer.appendChild(createSessionGroupHeader(group, currentGroupIds));
1654
- }
1655
- }
1656
- if (item.type === "loop") {
1657
- var loopEl = renderLoopGroup(item.loopId, item.children, item.groupKey);
1658
- if (loopEl) {
1659
- regularContainer.appendChild(loopEl);
1660
- }
1661
- } else if (item.type === "split-group") {
1662
- regularContainer.appendChild(renderSplitGroupItem(item.group, item.members));
1663
- } else if (item.type === "driver-hierarchy") {
1664
- regularContainer.appendChild(renderDesktopDriverHierarchy(item.root, renderSessionItem, function () { renderSessionList(null); }, searchMatchIds));
1665
- } else if (item.type === "orphan-workers") {
1666
- regularContainer.appendChild(renderDesktopOrphanHierarchy(item.workers, renderSessionItem, function () { renderSessionList(null); }, searchMatchIds));
1667
- } else {
1668
- regularContainer.appendChild(renderSessionItem(item.data));
1669
- }
1670
- }
1139
+ regularContainer.appendChild(renderFavoritesSection(layout, folderCtx));
1140
+ renderOrdinarySections(layout, folderCtx, regularContainer);
1671
1141
  getSessionListEl().appendChild(regularContainer);
1672
1142
  refreshIcons();
1673
1143
  if (updatePageTitle) updatePageTitle();
1674
- syncHeaderSearchUi();
1675
1144
  // Recorded from the sessions actually rendered, so a null-argument re-render
1676
1145
  // driven by other state refreshes the fingerprint rather than stranding it.
1677
1146
  lastRenderFingerprint = sessionRenderFingerprint(cachedSessions);
@@ -1680,68 +1149,19 @@ export function renderSessionList(sessions) {
1680
1149
  // --- Search results ---
1681
1150
 
1682
1151
  export function handleSearchResults(msg) {
1683
- if (msg.query !== searchQuery) return; // stale response
1684
- var ids = new Set();
1685
- for (var i = 0; i < msg.results.length; i++) {
1686
- ids.add(msg.results[i].id);
1687
- }
1688
- searchMatchIds = ids;
1689
- renderSessionList(null);
1152
+ applyListSearchResults(msg);
1690
1153
  }
1691
1154
 
1692
1155
  // --- Session presence ---
1693
1156
 
1694
1157
  export function updateSessionPresence(presence) {
1695
- sessionPresence = presence;
1158
+ setSessionPresence(presence);
1696
1159
  // Update presence avatars on existing session items without full re-render
1697
- var items = getSessionListEl().querySelectorAll("[data-session-id]");
1160
+ var items = getSessionListEl().querySelectorAll(".session-item[data-session-id]");
1698
1161
  for (var i = 0; i < items.length; i++) {
1699
- renderPresenceAvatars(items[i], items[i].dataset.sessionId);
1700
- }
1701
- }
1702
-
1703
- function presenceAvatarUrl(userOrStyle, seed) {
1704
- if (userOrStyle && typeof userOrStyle === "object") return userAvatarUrl(userOrStyle, 24);
1705
- return avatarUrl(userOrStyle || "imprint", seed, 24);
1706
- }
1707
-
1708
- function renderPresenceAvatars(el, sessionId) {
1709
- // Remove existing presence container
1710
- var existing = el.querySelector(".session-presence");
1711
- if (existing) existing.remove();
1712
-
1713
- var users = sessionPresence[sessionId];
1714
- if (!users || users.length === 0) return;
1715
-
1716
- var container = document.createElement("span");
1717
- container.className = "session-presence";
1718
-
1719
- var max = 3;
1720
- var shown = users.length > max ? max : users.length;
1721
- for (var i = 0; i < shown; i++) {
1722
- var u = users[i];
1723
- var img = document.createElement("img");
1724
- img.className = "session-presence-avatar";
1725
- img.src = presenceAvatarUrl(u);
1726
- img.alt = u.displayName;
1727
- img.dataset.tip = u.displayName + (u.username ? " (@" + u.username + ")" : "");
1728
- if (i > 0) img.style.marginLeft = "-6px";
1729
- container.appendChild(img);
1730
- }
1731
- if (users.length > max) {
1732
- var more = document.createElement("span");
1733
- more.className = "session-presence-more";
1734
- more.textContent = "+" + (users.length - max);
1735
- container.appendChild(more);
1736
- }
1737
-
1738
- // Insert before the more-btn
1739
- var moreBtn = el.querySelector(".session-more-btn");
1740
- if (moreBtn) {
1741
- el.insertBefore(container, moreBtn);
1742
- } else {
1743
- el.appendChild(container);
1162
+ renderSessionPresence(items[i], items[i].dataset.sessionId);
1744
1163
  }
1164
+ refreshMobileChatSheet();
1745
1165
  }
1746
1166
 
1747
1167
  // --- Session badge ---
@@ -1750,24 +1170,27 @@ export function updateSessionBadge(sessionId, count) {
1750
1170
  for (var i = 0; i < cachedSessions.length; i++) {
1751
1171
  if (cachedSessions[i].id === sessionId) cachedSessions[i].unread = count;
1752
1172
  }
1753
- var badge = document.querySelector('.session-unread-badge[data-session-id="' + sessionId + '"]');
1173
+ // Every copy of the row (a favorite shows twice) is updated.
1174
+ var badges = document.querySelectorAll('.session-unread-badge[data-session-id="' + sessionId + '"]');
1754
1175
  var groups = store.get('splitGroups') || [];
1755
1176
  for (var gi = 0; gi < groups.length; gi++) {
1756
1177
  if (groups[gi].members.indexOf(sessionId) === -1) continue;
1757
- badge = document.querySelector('.session-unread-badge[data-split-group-id="' + groups[gi].id + '"]');
1178
+ badges = document.querySelectorAll('.session-unread-badge[data-split-group-id="' + groups[gi].id + '"]');
1758
1179
  count = 0;
1759
1180
  for (var mi = 0; mi < cachedSessions.length; mi++) {
1760
1181
  if (groups[gi].members.indexOf(cachedSessions[mi].id) !== -1) count += cachedSessions[mi].unread || 0;
1761
1182
  }
1762
1183
  break;
1763
1184
  }
1764
- if (!badge) return;
1765
- if (count > 0) {
1766
- badge.textContent = count > 99 ? "99+" : String(count);
1767
- badge.classList.add("has-unread");
1768
- } else {
1769
- badge.textContent = "";
1770
- badge.classList.remove("has-unread");
1185
+ for (var bi = 0; bi < badges.length; bi++) {
1186
+ var badge = badges[bi];
1187
+ if (count > 0) {
1188
+ badge.textContent = count > 99 ? "99+" : String(count);
1189
+ badge.classList.add("has-unread");
1190
+ } else {
1191
+ badge.textContent = "";
1192
+ badge.classList.remove("has-unread");
1193
+ }
1771
1194
  }
1772
1195
  }
1773
1196