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

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 (58) 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 +12 -0
  11. package/lib/public/css/filebrowser.css +3 -3
  12. package/lib/public/css/fonts.css +6 -0
  13. package/lib/public/css/home-hub.css +52 -0
  14. package/lib/public/css/mates.css +1 -1
  15. package/lib/public/css/mobile-nav.css +0 -128
  16. package/lib/public/css/session-folders.css +402 -0
  17. package/lib/public/css/sidebar.css +28 -545
  18. package/lib/public/index.html +13 -22
  19. package/lib/public/modules/app-favicon.js +5 -4
  20. package/lib/public/modules/app-home-hub.js +60 -7
  21. package/lib/public/modules/app-messages.js +13 -0
  22. package/lib/public/modules/home-project-empty-access.js +44 -0
  23. package/lib/public/modules/input.js +3 -0
  24. package/lib/public/modules/project-activation.js +11 -0
  25. package/lib/public/modules/project-switcher.js +4 -4
  26. package/lib/public/modules/session-create-form.js +428 -0
  27. package/lib/public/modules/session-create.js +340 -0
  28. package/lib/public/modules/session-date-group-header.js +57 -0
  29. package/lib/public/modules/session-date-groups.js +84 -0
  30. package/lib/public/modules/session-folder-collapse.js +192 -0
  31. package/lib/public/modules/session-folder-context.js +215 -0
  32. package/lib/public/modules/session-folder-delete.js +339 -0
  33. package/lib/public/modules/session-folder-dialogs.js +173 -0
  34. package/lib/public/modules/session-folder-dnd.js +214 -0
  35. package/lib/public/modules/session-folder-draft.js +184 -0
  36. package/lib/public/modules/session-folder-layout.js +133 -0
  37. package/lib/public/modules/session-folder-toolbar.js +185 -0
  38. package/lib/public/modules/session-folder-view-menu.js +187 -0
  39. package/lib/public/modules/session-folders.js +97 -0
  40. package/lib/public/modules/session-list-search.js +108 -0
  41. package/lib/public/modules/session-presence-ui.js +44 -0
  42. package/lib/public/modules/sidebar-creation-effect.js +130 -0
  43. package/lib/public/modules/sidebar-mobile.js +39 -182
  44. package/lib/public/modules/sidebar-session-folders-ui.js +394 -0
  45. package/lib/public/modules/sidebar-sessions.js +142 -718
  46. package/lib/public/modules/sidebar.js +30 -7
  47. package/lib/public/modules/terminal.js +2 -2
  48. package/lib/public/modules/tool-palette.js +37 -219
  49. package/lib/public/style.css +1 -0
  50. package/lib/server.js +4 -0
  51. package/lib/session-folders.js +414 -0
  52. package/lib/sessions.js +7 -5
  53. package/lib/users-session-folder-preferences.js +69 -0
  54. package/lib/users.js +6 -0
  55. package/lib/ws-schema.js +11 -0
  56. package/package.json +1 -1
  57. package/lib/public/modules/session-creation-cta.js +0 -46
  58. package/lib/public/modules/tool-palette-overlays.js +0 -159
@@ -0,0 +1,108 @@
1
+ // Session list search: query, open state and results live in the store
2
+ // (sessionSearch) so the folder toolbar on every surface can drive and show them.
3
+ // Behavior is unchanged from the former header search: a debounced
4
+ // search_sessions request, stale responses ignored, matches filter the list while
5
+ // keeping Driver/Worker hierarchy.
6
+
7
+ import { store } from './store.js';
8
+ import { getWs } from './ws-ref.js';
9
+ import { hierarchyItemMatches } from './sidebar-session-hierarchy.js';
10
+
11
+ var DEBOUNCE_MS = 200;
12
+
13
+ function current() {
14
+ return store.get('sessionSearch') || { open: false, query: "", matchIds: null, timer: null, focus: false, selStart: null, selEnd: null };
15
+ }
16
+
17
+ function patch(change) {
18
+ store.set({ sessionSearch: Object.assign({}, current(), change) });
19
+ }
20
+
21
+ export function getListSearchQuery() {
22
+ return current().query;
23
+ }
24
+
25
+ // null means no search; otherwise a Set of matching session ids.
26
+ export function getListSearchMatchIds() {
27
+ return current().matchIds;
28
+ }
29
+
30
+ export function listSearchActive() {
31
+ var state = current();
32
+ return state.open || !!state.query;
33
+ }
34
+
35
+ function cancelTimer() {
36
+ var timer = current().timer;
37
+ if (timer) clearTimeout(timer);
38
+ }
39
+
40
+ export function openListSearch() {
41
+ patch({ open: true, focus: true, selStart: null, selEnd: null });
42
+ }
43
+
44
+ // Close always clears the query, so the list returns to its normal state.
45
+ export function closeListSearch() {
46
+ cancelTimer();
47
+ patch({ open: false, query: "", matchIds: null, timer: null, focus: false, selStart: null, selEnd: null });
48
+ }
49
+
50
+ // Clear empties the text but keeps the search open for another query.
51
+ export function clearListSearchText() {
52
+ cancelTimer();
53
+ patch({ query: "", matchIds: null, timer: null, focus: true, selStart: 0, selEnd: 0 });
54
+ }
55
+
56
+ export function setListSearchQuery(raw) {
57
+ var query = raw || "";
58
+ cancelTimer();
59
+ if (!query.trim()) {
60
+ patch({ query: query, matchIds: null, timer: null });
61
+ return;
62
+ }
63
+ var timer = setTimeout(function () {
64
+ var ws = getWs();
65
+ var state = current();
66
+ if (ws && store.get('connected') && state.query === query) ws.send(JSON.stringify({ type: "search_sessions", query: query }));
67
+ }, DEBOUNCE_MS);
68
+ patch({ query: query, timer: timer });
69
+ }
70
+
71
+ export function rememberListSearchCaret(focus, selStart, selEnd) {
72
+ var state = current();
73
+ if (state.focus === focus && state.selStart === selStart && state.selEnd === selEnd) return;
74
+ patch({ focus: focus, selStart: selStart, selEnd: selEnd });
75
+ }
76
+
77
+ export function applyListSearchResults(msg) {
78
+ if (msg.query !== current().query) return; // stale response
79
+ var ids = new Set();
80
+ for (var i = 0; i < msg.results.length; i++) ids.add(msg.results[i].id);
81
+ patch({ matchIds: ids });
82
+ }
83
+
84
+ // What a list rerender needs to react to; typing and caret movement do not.
85
+ export function listSearchNeedsRender(state, previous) {
86
+ var a = state.sessionSearch;
87
+ var b = previous.sessionSearch;
88
+ if (a === b) return false;
89
+ var after = a || { open: false, query: "", matchIds: null };
90
+ var before = b || { open: false, query: "", matchIds: null };
91
+ if (after.open !== before.open || after.matchIds !== before.matchIds) return true;
92
+ return after.query !== before.query && !after.query.trim();
93
+ }
94
+
95
+ // Shared by the desktop list and the mobile sheet: drops items with no match and
96
+ // keeps a Driver visible when it or any of its Workers match.
97
+ export function filterItemsBySearch(items, matchIds) {
98
+ if (matchIds === null) return items;
99
+ return items.filter(function (item) {
100
+ if (item.type === "session") return !!(item.data && matchIds.has(item.data.id));
101
+ if (item.type === "driver-hierarchy" || item.type === "orphan-workers") return hierarchyItemMatches(item, matchIds);
102
+ if (item.type === "split-group") {
103
+ for (var i = 0; i < item.members.length; i++) if (matchIds.has(item.members[i].id)) return true;
104
+ return false;
105
+ }
106
+ return true;
107
+ });
108
+ }
@@ -0,0 +1,44 @@
1
+ import { avatarUrl, userAvatarUrl } from './avatar.js';
2
+ import { store } from './store.js';
3
+
4
+ function avatarFor(userOrStyle, seed) {
5
+ if (userOrStyle && typeof userOrStyle === "object") return userAvatarUrl(userOrStyle, 24);
6
+ return avatarUrl(userOrStyle || "imprint", seed, 24);
7
+ }
8
+
9
+ export function setSessionPresence(presence) {
10
+ store.set({ sessionPresence: presence || {} });
11
+ }
12
+
13
+ export function renderSessionPresence(row, sessionId) {
14
+ var existing = row.querySelector(".session-presence");
15
+ if (existing) existing.remove();
16
+ var all = store.get('sessionPresence') || {};
17
+ var users = all[String(sessionId)] || all[sessionId];
18
+ if (!users || !users.length) return null;
19
+
20
+ var container = document.createElement("span");
21
+ container.className = "session-presence";
22
+ container.setAttribute("aria-label", users.length + (users.length === 1 ? " person viewing" : " people viewing"));
23
+ var max = 3;
24
+ var shown = Math.min(max, users.length);
25
+ for (var i = 0; i < shown; i++) {
26
+ var user = users[i];
27
+ var image = document.createElement("img");
28
+ image.className = "session-presence-avatar";
29
+ image.src = avatarFor(user);
30
+ image.alt = user.displayName || "Viewer";
31
+ image.dataset.tip = (user.displayName || "Viewer") + (user.username ? " (@" + user.username + ")" : "");
32
+ container.appendChild(image);
33
+ }
34
+ if (users.length > max) {
35
+ var more = document.createElement("span");
36
+ more.className = "session-presence-more";
37
+ more.textContent = "+" + (users.length - max);
38
+ container.appendChild(more);
39
+ }
40
+
41
+ var anchor = row.querySelector(".session-item-age, .mobile-worker-generation, .mobile-session-unread, .session-row-actions, .mobile-session-star");
42
+ row.insertBefore(container, anchor || null);
43
+ return container;
44
+ }
@@ -0,0 +1,130 @@
1
+ // A small acknowledgement-only title shimmer for locally created sidebar
2
+ // folders and sessions. The queue lives in the store so list rendering and the
3
+ // acknowledgement may arrive in either order without replaying the effect.
4
+
5
+ import { store } from './store.js';
6
+
7
+ var MAX_EFFECTS = 4;
8
+ var MAX_ACTIVE_SHIMMERS = 4;
9
+ var MAX_ATTEMPTS = 40;
10
+ var RETRY_MS = 50;
11
+ var SHIMMER_MS = 5700;
12
+
13
+ function prefersReducedMotion() {
14
+ return window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
15
+ }
16
+
17
+ function effectKey(effect) {
18
+ return effect.kind + ':' + effect.requestId;
19
+ }
20
+
21
+ function visibleElement(nodes) {
22
+ for (var i = 0; i < nodes.length; i++) {
23
+ if (nodes[i].getClientRects().length > 0) return nodes[i];
24
+ }
25
+ return null;
26
+ }
27
+
28
+ function folderTarget(effect) {
29
+ var sections = document.querySelectorAll('.session-folder[data-folder-id]');
30
+ var matches = [];
31
+ for (var i = 0; i < sections.length; i++) {
32
+ if (sections[i].dataset.folderId === String(effect.targetId) && !sections[i].classList.contains('session-folder-favorites')) matches.push(sections[i]);
33
+ }
34
+ var section = visibleElement(matches);
35
+ return section ? section.querySelector('.session-folder-label') : null;
36
+ }
37
+
38
+ function sessionTarget(effect) {
39
+ var sections = document.querySelectorAll('.session-folder[data-folder-id]');
40
+ var matches = [];
41
+ for (var i = 0; i < sections.length; i++) {
42
+ if (sections[i].dataset.folderId !== String(effect.folderId) || sections[i].classList.contains('session-folder-favorites')) continue;
43
+ var units = sections[i].querySelectorAll('.session-folder-unit[data-unit-key]');
44
+ for (var j = 0; j < units.length; j++) {
45
+ if (units[j].dataset.unitKey !== String(effect.targetId)) continue;
46
+ var title = units[j].querySelector('.session-item-title, .mobile-session-title');
47
+ if (title) matches.push(title);
48
+ }
49
+ }
50
+ return visibleElement(matches);
51
+ }
52
+
53
+ function findTarget(effect) {
54
+ return effect.kind === 'folder' ? folderTarget(effect) : sessionTarget(effect);
55
+ }
56
+
57
+ function removeEffectDom() {
58
+ var targets = document.querySelectorAll('.sidebar-creation-shimmer');
59
+ for (var i = 0; i < targets.length; i++) {
60
+ targets[i].classList.remove('sidebar-creation-shimmer');
61
+ delete targets[i].dataset.creationEffect;
62
+ }
63
+ }
64
+
65
+ function playEffect(effect, target) {
66
+ if (!target.isConnected || prefersReducedMotion()) return;
67
+ var active = document.querySelectorAll('.sidebar-creation-shimmer');
68
+ for (var i = 0; i <= active.length - MAX_ACTIVE_SHIMMERS; i++) {
69
+ active[i].classList.remove('sidebar-creation-shimmer');
70
+ delete active[i].dataset.creationEffect;
71
+ }
72
+ target.classList.remove('sidebar-creation-shimmer');
73
+ void target.offsetWidth;
74
+ target.dataset.creationEffect = effectKey(effect);
75
+ target.classList.add('sidebar-creation-shimmer');
76
+ setTimeout(function () {
77
+ if (target.dataset.creationEffect !== effectKey(effect)) return;
78
+ target.classList.remove('sidebar-creation-shimmer');
79
+ delete target.dataset.creationEffect;
80
+ }, SHIMMER_MS);
81
+ }
82
+
83
+ export function clearSidebarCreationEffects() {
84
+ if ((store.get('sidebarCreationEffects') || []).length) store.set({ sidebarCreationEffects: [] });
85
+ removeEffectDom();
86
+ }
87
+
88
+ export function flushSidebarCreationEffects() {
89
+ var queue = store.get('sidebarCreationEffects') || [];
90
+ if (!queue.length) return;
91
+ if (prefersReducedMotion()) {
92
+ clearSidebarCreationEffects();
93
+ return;
94
+ }
95
+ var slug = store.get('currentSlug');
96
+ var pending = [];
97
+ var ready = [];
98
+ for (var i = 0; i < queue.length; i++) {
99
+ var effect = queue[i];
100
+ if (effect.slug !== slug) continue;
101
+ var target = findTarget(effect);
102
+ if (target) {
103
+ ready.push({ effect: effect, target: target });
104
+ } else if (effect.attempts < MAX_ATTEMPTS) {
105
+ pending.push(Object.assign({}, effect, { attempts: effect.attempts + 1 }));
106
+ }
107
+ }
108
+ // Consume before touching the DOM. A render or duplicate acknowledgement can
109
+ // therefore never play an already resolved effect a second time.
110
+ store.set({ sidebarCreationEffects: pending });
111
+ for (var j = 0; j < ready.length; j++) playEffect(ready[j].effect, ready[j].target);
112
+ if (pending.length) setTimeout(flushSidebarCreationEffects, RETRY_MS);
113
+ }
114
+
115
+ export function queueSidebarCreationEffect(kind, requestId, targetId, folderId) {
116
+ if (!requestId || targetId === undefined || targetId === null || prefersReducedMotion()) return false;
117
+ var queue = (store.get('sidebarCreationEffects') || []).slice();
118
+ var next = { kind: kind, requestId: requestId, targetId: targetId, folderId: folderId || null, slug: store.get('currentSlug'), attempts: 0 };
119
+ var key = effectKey(next);
120
+ for (var i = 0; i < queue.length; i++) if (effectKey(queue[i]) === key) return false;
121
+ queue.push(next);
122
+ if (queue.length > MAX_EFFECTS) queue = queue.slice(queue.length - MAX_EFFECTS);
123
+ store.set({ sidebarCreationEffects: queue });
124
+ setTimeout(flushSidebarCreationEffects, 0);
125
+ return true;
126
+ }
127
+
128
+ store.subscribe(function (state, previous) {
129
+ if (state.currentSlug !== previous.currentSlug || state.dmMode !== previous.dmMode || (previous.connected && !state.connected)) clearSidebarCreationEffects();
130
+ });
@@ -9,15 +9,15 @@ import { parseEmojis } from './markdown.js';
9
9
  import { getCurrentTheme, getChatLayout, setChatLayout } from './theme.js';
10
10
  import { openCommandPalette } from './command-palette.js';
11
11
  import { openProjectSettings } from './project-settings.js';
12
+ import { getCachedSessions, openSessionContextMenu } from './sidebar-sessions.js';
13
+ import { VENDOR_AVATARS } from './app-rendering.js';
14
+ import { renderSessionPresence } from './session-presence-ui.js';
12
15
  import {
13
- getCachedSessions,
14
- getDateGroup,
15
- resolveDefaultVendor,
16
- startNewSession
17
- } from './sidebar-sessions.js';
18
- import { VENDOR_AVATARS, VENDOR_NAMES, VENDOR_ORDER, VENDOR_HOMEPAGES, isExperimentalVendor } from './app-rendering.js';
19
- import { defaultVendorUnavailable, resolvePreferredVendor, saveDefaultVendor } from './default-vendor.js';
20
- import { renderSessionCreationCta } from './session-creation-cta.js';
16
+ canOrganizeSession, computeLayout, createStarButton, renderFavoritesSection, renderOrdinarySections
17
+ } from './sidebar-session-folders-ui.js';
18
+ import { renderFolderToolbar, captureFolderInputs } from './session-folder-toolbar.js';
19
+ import { captureFolderCollapseAnimations } from './session-folder-collapse.js';
20
+ import { getListSearchMatchIds, filterItemsBySearch } from './session-list-search.js';
21
21
  import {
22
22
  getCachedProjectList,
23
23
  getCachedCurrentSlug,
@@ -124,7 +124,7 @@ export function openMobileSheet(type) {
124
124
  refreshIcons();
125
125
  }
126
126
 
127
- function closeMobileSheet() {
127
+ export function closeMobileSheet() {
128
128
  var sheet = document.getElementById("mobile-sheet");
129
129
  if (!sheet || sheet.classList.contains("hidden")) return;
130
130
 
@@ -387,6 +387,7 @@ function createMobileSessionItem(s, options) {
387
387
  var itemOptions = options || {};
388
388
  var el = document.createElement("div");
389
389
  el.className = "mobile-session-item" + (s.active ? " active" : "");
390
+ el.dataset.sessionId = String(s.id);
390
391
  if (itemOptions.worker) el.classList.add("mobile-worker-item");
391
392
 
392
393
  // Vendor badge (legacy sessions without a vendor are Claude)
@@ -414,6 +415,7 @@ function createMobileSessionItem(s, options) {
414
415
  titleSpan.appendChild(document.createTextNode(s.title || "New Session"));
415
416
  el.appendChild(titleSpan);
416
417
  appendGithubWork(el, s, titleSpan);
418
+ renderSessionPresence(el, String(s.id));
417
419
 
418
420
  if (itemOptions.worker) {
419
421
  var generation = document.createElement("span");
@@ -430,6 +432,16 @@ function createMobileSessionItem(s, options) {
430
432
  el.appendChild(badge);
431
433
  }
432
434
 
435
+ if (!itemOptions.worker && canOrganizeSession(s)) {
436
+ el.appendChild(createStarButton(s, "mobile-session-star"));
437
+ }
438
+
439
+ el.addEventListener("contextmenu", function (event) {
440
+ event.preventDefault();
441
+ event.stopPropagation();
442
+ openSessionContextMenu(el, s);
443
+ });
444
+
433
445
  (function (id) {
434
446
  el.addEventListener("click", function () {
435
447
  if (getWs() && store.get('connected')) {
@@ -680,125 +692,6 @@ function createMobileLoopGroup(loopId, children, groupKey) {
680
692
 
681
693
  // Helper: render sorted sessions into a container with date groups (with loop session grouping)
682
694
  export function renderMobileSessionsInto(container) {
683
- var mobileDefaultVendor = resolvePreferredVendor(store.get('installedVendors') || []) || resolveDefaultVendor();
684
- var mobileInstalled = store.get('installedVendors') || [];
685
- var mobileDefaultState = store.get('defaultVendorState') || {};
686
-
687
- var vendorList = document.createElement("div");
688
- vendorList.className = "mobile-vendor-list hidden";
689
- vendorList.id = "mobile-session-provider-menu";
690
- var mobileVendorName = VENDOR_NAMES[mobileDefaultVendor] || mobileDefaultVendor;
691
- var mobileCreateTitle = "Create a " + mobileVendorName + " session";
692
- if (defaultVendorUnavailable(mobileInstalled)) mobileCreateTitle += " (saved default is unavailable; using installed priority)";
693
- var controls;
694
- controls = renderSessionCreationCta({
695
- rowClass: "mobile-session-new-row",
696
- createClass: "mobile-session-new mobile-session-new-main",
697
- providerClass: "mobile-session-new mobile-session-new-provider",
698
- iconClass: "mobile-session-new-icon",
699
- vendorKey: mobileDefaultVendor,
700
- vendorName: mobileVendorName,
701
- vendorAvatar: VENDOR_AVATARS[mobileDefaultVendor] || VENDOR_AVATARS.claude,
702
- createTitle: mobileCreateTitle,
703
- providerTitle: "Switch AI provider (current: " + mobileVendorName + ")",
704
- menuId: vendorList.id,
705
- onCreate: function () { startNewSession(mobileDefaultVendor); closeMobileSheet(); },
706
- onProvider: function () {
707
- var opening = vendorList.classList.contains("hidden");
708
- vendorList.classList.toggle("hidden", !opening);
709
- controls.providerButton.classList.toggle("expanded", opening);
710
- controls.providerButton.setAttribute("aria-expanded", opening ? "true" : "false");
711
- if (opening) {
712
- var first = vendorList.querySelector("button:not(:disabled)");
713
- if (first) first.focus();
714
- }
715
- },
716
- });
717
- var newRow = controls.element;
718
- vendorList.addEventListener("keydown", function (e) {
719
- if (e.key !== "Escape") return;
720
- e.preventDefault();
721
- vendorList.classList.add("hidden");
722
- controls.providerButton.classList.remove("expanded");
723
- controls.providerButton.setAttribute("aria-expanded", "false");
724
- controls.providerButton.focus();
725
- });
726
-
727
- if (defaultVendorUnavailable(mobileInstalled) && mobileDefaultState.preference) {
728
- var mobileFallbackNote = document.createElement("div");
729
- mobileFallbackNote.className = "mobile-session-new-preference-status mobile-session-new-fallback-note";
730
- mobileFallbackNote.textContent = "Saved default " + (VENDOR_NAMES[mobileDefaultState.preference] || mobileDefaultState.preference) + " is unavailable. Using " + (VENDOR_NAMES[mobileDefaultVendor] || mobileDefaultVendor) + ".";
731
- }
732
-
733
- // Every known vendor is listed. Uninstalled ones stay visible but disabled,
734
- // and tapping one opens the vendor's homepage.
735
- for (var vi = 0; vi < VENDOR_ORDER.length; vi++) {
736
- (function (vendor) {
737
- var isInstalled = mobileInstalled.indexOf(vendor) !== -1;
738
- var name = VENDOR_NAMES[vendor] || vendor;
739
- var vendorRow = document.createElement("div");
740
- vendorRow.className = "mobile-vendor-row";
741
- var vBtn = document.createElement("button");
742
- vBtn.className = "mobile-session-new mobile-session-new-vendor";
743
- if (!isInstalled) vBtn.classList.add("disabled");
744
- if (vendor === mobileDefaultVendor) vBtn.classList.add("active");
745
- vBtn.innerHTML = '<img src="' + VENDOR_AVATARS[vendor] + '" class="mobile-session-new-icon" alt="">' +
746
- '<span>' + (isInstalled ? "Create with " : "Learn about ") + name + '</span>' +
747
- (isExperimentalVendor(vendor) ? '<span class="vendor-experimental-badge" aria-label="Experimental integration" title="Experimental integration; not yet validated through direct use testing">🧪</span>' : '') +
748
- (isInstalled ? "" : '<span class="mobile-vendor-note">Not installed</span>');
749
- vBtn.addEventListener("click", function () {
750
- if (!isInstalled) {
751
- window.open(VENDOR_HOMEPAGES[vendor], "_blank", "noopener");
752
- return;
753
- }
754
- vendorList.classList.add("hidden");
755
- controls.providerButton.classList.remove("expanded");
756
- controls.providerButton.setAttribute("aria-expanded", "false");
757
- startNewSession(vendor);
758
- closeMobileSheet();
759
- });
760
- vendorRow.appendChild(vBtn);
761
- if (isInstalled) {
762
- var defaultBtn = document.createElement("button");
763
- defaultBtn.type = "button";
764
- defaultBtn.className = "mobile-vendor-set-default";
765
- defaultBtn.textContent = mobileDefaultState.saving ? "Saving…" : (vendor === mobileDefaultVendor && !defaultVendorUnavailable(mobileInstalled) ? "Default" : "Set default");
766
- defaultBtn.disabled = mobileDefaultState.saving === true;
767
- defaultBtn.setAttribute("aria-label", "Set " + name + " as default vendor");
768
- defaultBtn.addEventListener("click", function (e) {
769
- e.preventDefault();
770
- e.stopPropagation();
771
- saveDefaultVendor(vendor);
772
- });
773
- vendorRow.appendChild(defaultBtn);
774
- }
775
- vendorList.appendChild(vendorRow);
776
- })(VENDOR_ORDER[vi]);
777
- }
778
-
779
- var automaticBtn = document.createElement("button");
780
- automaticBtn.className = "mobile-session-new mobile-session-new-vendor mobile-session-new-automatic";
781
- automaticBtn.type = "button";
782
- automaticBtn.textContent = "Use automatic default";
783
- automaticBtn.disabled = mobileDefaultState.saving === true;
784
- automaticBtn.addEventListener("click", function (e) {
785
- e.preventDefault();
786
- e.stopPropagation();
787
- saveDefaultVendor("");
788
- });
789
- vendorList.appendChild(automaticBtn);
790
- if (mobileDefaultState.error) {
791
- var mobileStatus = document.createElement("div");
792
- mobileStatus.className = "mobile-session-new-preference-status";
793
- mobileStatus.setAttribute("role", "alert");
794
- mobileStatus.textContent = mobileDefaultState.error;
795
- vendorList.appendChild(mobileStatus);
796
- }
797
-
798
- container.appendChild(newRow);
799
- if (mobileFallbackNote) container.appendChild(mobileFallbackNote);
800
- container.appendChild(vendorList);
801
-
802
695
  // Partition: loop sessions vs normal sessions (same logic as desktop renderSessionList)
803
696
  var sessions = getCachedSessions();
804
697
  var hierarchy = prepareSidebarHierarchy(sessions);
@@ -846,60 +739,22 @@ export function renderMobileSessionsInto(container) {
846
739
  items.push({ type: "loop", loopId: realLoopId, groupKey: gk, children: children, lastActivity: maxActivity });
847
740
  }
848
741
 
849
- // Sort by lastActivity descending
850
- items.sort(function (a, b) {
851
- var aBookmarked = !!(a.type === "loop" || a.type === "orphan-workers" ? false : a.data && a.data.bookmarked);
852
- var bBookmarked = !!(b.type === "loop" || b.type === "orphan-workers" ? false : b.data && b.data.bookmarked);
853
- if (aBookmarked !== bBookmarked) return aBookmarked ? -1 : 1;
854
- return (b.lastActivity || 0) - (a.lastActivity || 0);
855
- });
856
-
857
- var bookmarkedItems = [];
858
- var regularItems = [];
859
- for (var n = 0; n < items.length; n++) {
860
- var item = items[n];
861
- if ((item.type === "session" || item.type === "driver-hierarchy") && item.data && item.data.bookmarked) {
862
- bookmarkedItems.push(item);
863
- } else {
864
- regularItems.push(item);
865
- }
866
- }
867
-
868
- if (bookmarkedItems.length > 0) {
869
- var bookmarkedHeader = document.createElement("div");
870
- bookmarkedHeader.className = "mobile-sheet-group";
871
- bookmarkedHeader.textContent = "Favorites";
872
- container.appendChild(bookmarkedHeader);
873
-
874
- for (var bi = 0; bi < bookmarkedItems.length; bi++) {
875
- if (bookmarkedItems[bi].type === "driver-hierarchy") container.appendChild(renderMobileDriverHierarchy(bookmarkedItems[bi].root, createMobileSessionItem, refreshMobileChatSheet));
876
- else container.appendChild(createMobileSessionItem(bookmarkedItems[bi].data));
877
- }
878
- }
879
-
880
- var currentGroup = "";
881
- for (var ri = 0; ri < regularItems.length; ri++) {
882
- var item = regularItems[ri];
883
- var group = getDateGroup(item.lastActivity || 0);
884
- if (group !== currentGroup) {
885
- currentGroup = group;
886
- if (group !== "Today") {
887
- var header = document.createElement("div");
888
- header.className = "mobile-sheet-group";
889
- header.textContent = group;
890
- container.appendChild(header);
891
- }
892
- }
893
- if (item.type === "loop") {
894
- container.appendChild(createMobileLoopGroup(item.loopId, item.children, item.groupKey));
895
- } else if (item.type === "driver-hierarchy") {
896
- container.appendChild(renderMobileDriverHierarchy(item.root, createMobileSessionItem, refreshMobileChatSheet));
897
- } else if (item.type === "orphan-workers") {
898
- container.appendChild(renderMobileOrphanHierarchy(item.workers, createMobileSessionItem, refreshMobileChatSheet));
899
- } else {
900
- container.appendChild(createMobileSessionItem(item.data));
901
- }
902
- }
742
+ var matchIds = getListSearchMatchIds();
743
+ var layout = computeLayout(filterItemsBySearch(items, matchIds), matchIds !== null, "mobile");
744
+ var folderCtx = {
745
+ surface: "mobile",
746
+ searching: matchIds !== null,
747
+ sessions: getCachedSessions(),
748
+ renderItem: function (it) {
749
+ if (it.type === "loop") return createMobileLoopGroup(it.loopId, it.children, it.groupKey);
750
+ if (it.type === "driver-hierarchy") return renderMobileDriverHierarchy(it.root, createMobileSessionItem, refreshMobileChatSheet);
751
+ if (it.type === "orphan-workers") return renderMobileOrphanHierarchy(it.workers, createMobileSessionItem, refreshMobileChatSheet);
752
+ return createMobileSessionItem(it.data);
753
+ },
754
+ };
755
+ container.appendChild(renderFolderToolbar("mobile"));
756
+ container.appendChild(renderFavoritesSection(layout, folderCtx));
757
+ renderOrdinarySections(layout, folderCtx, container);
903
758
  }
904
759
 
905
760
  // Refresh mobile chat sheet when session data updates (called from renderSessionList)
@@ -944,6 +799,8 @@ export function refreshMobileChatSheet() {
944
799
  }
945
800
 
946
801
  // Re-render sessions for current context
802
+ captureFolderInputs();
803
+ captureFolderCollapseAnimations(sessionListEl);
947
804
  sessionListEl.innerHTML = "";
948
805
  renderMobileSessionsInto(sessionListEl);
949
806