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,394 @@
1
+ // Folder sections for the project session sidebar, shared by the desktop
2
+ // list and the mobile sheet. Layout math lives in session-folder-layout.js and
3
+ // persistence in session-folders.js; this module only builds DOM and routes
4
+ // user intent (click, menu, drag) to folder operations.
5
+
6
+ import { store } from './store.js';
7
+ import { escapeHtml } from './utils.js';
8
+ import { iconHtml, refreshIcons } from './icons.js';
9
+ import { currentFolderState, sendFolderOp, openFolderCreate, cancelFolderCreate, resetSessionFolders, requestFolderState } from './session-folders.js';
10
+ import { closeViewMenu } from './session-folder-view-menu.js';
11
+ import { wireFolderDrag, cancelFolderDrag, reapplyFolderDragPreview } from './session-folder-dnd.js';
12
+ import { renderFolderDraft } from './session-folder-draft.js';
13
+ import { closeFolderMenu, wireFolderContext, resetFolderGestures, consumeFolderClickSuppress } from './session-folder-context.js';
14
+ import {
15
+ buildLayout, containerKeys, containerOf, orderAfterMove,
16
+ FAVORITES_ID, UNFILED_ID, isFavorite
17
+ } from './session-folder-layout.js';
18
+ import { openFolderNameDialog, openFolderPicker, closeFolderModal } from './session-folder-dialogs.js';
19
+ import { openFolderDelete } from './session-folder-delete.js';
20
+ import { openSessionCreate } from './session-create.js';
21
+ import { renderNewSessionButton, renderCreateRow } from './session-create-form.js';
22
+ import { restoreFolderCollapse, toggleFolderCollapse } from './session-folder-collapse.js';
23
+ import { groupUnitsByDate, groupRootSessionIds } from './session-date-groups.js';
24
+ import { renderDateGroupHeader } from './session-date-group-header.js';
25
+
26
+ // Mutable UI state (open menu, drag in flight, the layout each surface last
27
+ // rendered) lives in the store; this module only owns the functions.
28
+
29
+ store.subscribe(function (state, previous) {
30
+ var slugChanged = state.currentSlug !== previous.currentSlug;
31
+ if (slugChanged || state.dmMode !== previous.dmMode) {
32
+ closeFolderMenu();
33
+ resetFolderGestures();
34
+ closeFolderModal();
35
+ closeViewMenu(false);
36
+ cancelFolderCreate();
37
+ cancelFolderDrag();
38
+ store.set({ sessionFolderDrag: null });
39
+ }
40
+ if (previous.connected && !state.connected) cancelFolderDrag();
41
+ if (slugChanged) {
42
+ resetSessionFolders();
43
+ requestFolderState();
44
+ }
45
+ });
46
+
47
+ function layoutFor(surface) {
48
+ return (store.get('sessionFolderLayouts') || {})[surface] || null;
49
+ }
50
+
51
+ function dragState() {
52
+ return store.get('sessionFolderDrag');
53
+ }
54
+
55
+ function setDrag(value) {
56
+ store.set({ sessionFolderDrag: value });
57
+ }
58
+
59
+ // --- Units ---
60
+
61
+ export function unitsFromItems(items) {
62
+ var units = [];
63
+ for (var i = 0; i < items.length; i++) {
64
+ var item = items[i];
65
+ var data = item.type === "session" || item.type === "driver-hierarchy" ? item.data : null;
66
+ units.push({
67
+ key: data && typeof data.id === "number" ? data.id : null,
68
+ title: data ? data.title : (item.type === "split-group" ? (item.group && item.group.name) : (item.type === "loop" ? item.loopId : "")),
69
+ createdAt: data ? data.createdAt || 0 : 0,
70
+ lastActivity: data ? (data.lastActivity || 0) : (item.lastActivity || 0),
71
+ item: item,
72
+ });
73
+ }
74
+ return units;
75
+ }
76
+
77
+ export function computeLayout(items, searching, surface) {
78
+ var layout = buildLayout(unitsFromItems(items), currentFolderState(), { searching: searching });
79
+ var layouts = Object.assign({}, store.get('sessionFolderLayouts') || {});
80
+ layouts[surface] = layout;
81
+ store.set({ sessionFolderLayouts: layouts });
82
+ return layout;
83
+ }
84
+
85
+ // A session can be filed when it is a standalone session or a Driver root.
86
+ export function canOrganizeSession(session) {
87
+ return !!(session && typeof session.id === "number" && session.sessionRole !== "worker" && !(session.loop && session.loop.loopId));
88
+ }
89
+
90
+ export function currentContainerOf(session) {
91
+ return containerOf(currentFolderState(), { key: session.id });
92
+ }
93
+
94
+ // --- Actions ---
95
+
96
+ // Moves the real placement (a custom folder or Unfiled). The Favorites tag is
97
+ // never touched by a move.
98
+ export function moveSessionToFolder(session, folderId) {
99
+ if (!canOrganizeSession(session)) return;
100
+ sendFolderOp({ op: "place_session", sessionId: session.id, folderId: folderId || null });
101
+ }
102
+
103
+ export function isFavoriteSession(session) {
104
+ return !!session && isFavorite(currentFolderState(), session.id);
105
+ }
106
+
107
+ // The Favorites tag only: the session keeps its folder.
108
+ export function toggleFavorite(session) {
109
+ if (!canOrganizeSession(session)) return;
110
+ sendFolderOp({ op: "set_favorite", sessionId: session.id, favorite: !isFavoriteSession(session) });
111
+ }
112
+
113
+ // A filled or unfilled star on a session row; the same state from either
114
+ // appearance of the session (its folder and Favorites).
115
+ export function createStarButton(session, className) {
116
+ var on = isFavoriteSession(session);
117
+ var title = session.title || "New Session";
118
+ var btn = document.createElement("button");
119
+ btn.type = "button";
120
+ btn.className = (className || "session-folder-star-btn") + (on ? " is-favorite" : "");
121
+ btn.setAttribute("aria-pressed", String(on));
122
+ btn.setAttribute("aria-label", (on ? "Remove from Favorites: " : "Add to Favorites: ") + title);
123
+ btn.title = on ? "Remove from Favorites" : "Add to Favorites";
124
+ btn.innerHTML = iconHtml("star");
125
+ btn.addEventListener("click", function (event) {
126
+ event.preventDefault();
127
+ event.stopPropagation();
128
+ toggleFavorite(session);
129
+ });
130
+ return btn;
131
+ }
132
+
133
+ export function openMoveToFolder(session) {
134
+ if (!canOrganizeSession(session)) return;
135
+ var state = currentFolderState();
136
+ openFolderPicker({
137
+ title: "Move to folder",
138
+ folders: state.folders,
139
+ current: currentContainerOf(session),
140
+ onPick: function (folderId) { moveSessionToFolder(session, folderId); },
141
+ onNewFolder: function () { openFolderCreate(session.id); },
142
+ });
143
+ }
144
+
145
+ function renameFolder(folder) {
146
+ openFolderNameDialog({
147
+ title: "Rename folder", submitLabel: "Rename", initial: folder.label, selfId: folder.id, existing: currentFolderState().folders,
148
+ onSubmit: function (name) { sendFolderOp({ op: "rename_folder", folderId: folder.id, name: name }); },
149
+ });
150
+ }
151
+
152
+ // The dialog asks the server for the folder's real membership, so a search
153
+ // filter or a collapsed section never changes what the choice covers.
154
+ function deleteFolder(folder) {
155
+ openFolderDelete(folder.id, folder.label);
156
+ }
157
+
158
+ function shiftFolder(folderId, delta) {
159
+ var folders = currentFolderState().folders;
160
+ for (var i = 0; i < folders.length; i++) {
161
+ if (folders[i].id !== folderId) continue;
162
+ var neighbor = folders[i + delta];
163
+ if (neighbor) sendFolderOp({ op: "reorder_folder", folderId: folderId, targetId: neighbor.id, insertBefore: delta < 0 });
164
+ }
165
+ }
166
+
167
+ // --- Drag and drop (desktop) ---
168
+
169
+ function clearDropMarks() {
170
+ var marked = document.querySelectorAll(".session-folder-drop, .drag-over-above, .drag-over-below, .session-folder-unit.dragging");
171
+ for (var i = 0; i < marked.length; i++) marked[i].classList.remove("session-folder-drop", "drag-over-above", "drag-over-below", "dragging");
172
+ }
173
+
174
+ export function setupFolderDrag(el, session) {
175
+ if (!canOrganizeSession(session)) return;
176
+ el.setAttribute("draggable", "true");
177
+ el.addEventListener("dragstart", function (event) {
178
+ // The section the drag started in decides whether it is a reorder of the
179
+ // Favorites tag or a move of the real placement.
180
+ var from = el.closest(".session-folder");
181
+ setDrag({ type: "session", key: session.id, containerId: from ? from.dataset.folderId : currentContainerOf(session) });
182
+ event.dataTransfer.effectAllowed = "move";
183
+ event.dataTransfer.setData("text/plain", String(session.id));
184
+ var unit = el.closest(".session-folder-unit");
185
+ setTimeout(function () { (unit || el).classList.add("dragging"); }, 0);
186
+ });
187
+ el.addEventListener("dragend", function () { setDrag(null); clearDropMarks(); });
188
+ }
189
+
190
+ function reorderable(containerId) {
191
+ var state = currentFolderState();
192
+ return !searchingNow() && (containerId === FAVORITES_ID || state.view.sort === "manual");
193
+ }
194
+
195
+ function searchingNow() {
196
+ var layout = layoutFor("desktop");
197
+ return !!(layout && layout.searching);
198
+ }
199
+
200
+ function dropSession(targetContainerId, targetKey, insertBefore) {
201
+ var dragged = dragState();
202
+ setDrag(null);
203
+ var lastLayout = layoutFor("desktop");
204
+ if (!dragged || dragged.type !== "session" || !lastLayout) return;
205
+ var key = dragged.key;
206
+ var src = dragged.containerId;
207
+ var canOrder = reorderable(targetContainerId);
208
+ var ordered = containerKeys(lastLayout, targetContainerId);
209
+ if (targetContainerId === src) {
210
+ if (canOrder && targetKey && targetKey !== key) sendFolderOp({ op: "set_order", containerKey: targetContainerId, order: orderAfterMove(ordered, key, targetKey, insertBefore) });
211
+ return;
212
+ }
213
+ if (targetContainerId === FAVORITES_ID) {
214
+ // Dropping on Favorites tags the session; it stays in its folder.
215
+ sendFolderOp({ op: "set_favorite", sessionId: key, favorite: true, order: canOrder ? orderAfterMove(ordered, key, targetKey || null, insertBefore) : undefined });
216
+ return;
217
+ }
218
+ sendFolderOp({
219
+ op: "place_session", sessionId: key, folderId: targetContainerId === UNFILED_ID ? null : targetContainerId,
220
+ order: canOrder ? orderAfterMove(ordered, key, targetKey || null, insertBefore) : undefined,
221
+ });
222
+ }
223
+
224
+ function wireContainerDrop(el, containerId) {
225
+ el.addEventListener("dragover", function (event) {
226
+ var drag = dragState();
227
+ if (!drag || drag.type !== "session") return;
228
+ event.preventDefault();
229
+ event.dataTransfer.dropEffect = "move";
230
+ el.classList.add("session-folder-drop");
231
+ });
232
+ el.addEventListener("dragleave", function (event) {
233
+ if (!el.contains(event.relatedTarget)) el.classList.remove("session-folder-drop");
234
+ });
235
+ el.addEventListener("drop", function (event) {
236
+ var drag = dragState();
237
+ if (!drag || drag.type !== "session") return;
238
+ event.preventDefault();
239
+ clearDropMarks();
240
+ dropSession(containerId, null, false);
241
+ });
242
+ }
243
+
244
+ function wireUnitDrop(wrapper, containerId, key) {
245
+ wrapper.addEventListener("dragover", function (event) {
246
+ var drag = dragState();
247
+ if (!drag || drag.type !== "session" || drag.key === key) return;
248
+ event.preventDefault();
249
+ event.stopPropagation();
250
+ event.dataTransfer.dropEffect = "move";
251
+ wrapper.classList.remove("drag-over-above", "drag-over-below");
252
+ if (reorderable(containerId)) {
253
+ var rect = wrapper.getBoundingClientRect();
254
+ wrapper.classList.add(event.clientY < rect.top + rect.height / 2 ? "drag-over-above" : "drag-over-below");
255
+ }
256
+ });
257
+ wrapper.addEventListener("dragleave", function () { wrapper.classList.remove("drag-over-above", "drag-over-below"); });
258
+ wrapper.addEventListener("drop", function (event) {
259
+ var drag = dragState();
260
+ if (!drag || drag.type !== "session" || drag.key === key) return;
261
+ event.preventDefault();
262
+ event.stopPropagation();
263
+ var rect = wrapper.getBoundingClientRect();
264
+ var before = event.clientY < rect.top + rect.height / 2;
265
+ clearDropMarks();
266
+ dropSession(containerId, key, before);
267
+ });
268
+ }
269
+
270
+ // --- Rendering ---
271
+
272
+ function countUnits(units) {
273
+ var total = 0;
274
+ for (var i = 0; i < units.length; i++) {
275
+ var item = units[i].item;
276
+ total += item.type === "driver-hierarchy" ? 1 + item.root.workers.length : 1;
277
+ }
278
+ return total;
279
+ }
280
+
281
+ function renderUnit(unit, containerId, ctx) {
282
+ var wrapper = document.createElement("div");
283
+ wrapper.className = "session-folder-unit";
284
+ if (unit.key) {
285
+ wrapper.dataset.unitKey = String(unit.key);
286
+ if (ctx.surface === "desktop") wireUnitDrop(wrapper, containerId, unit.key);
287
+ }
288
+ wrapper.appendChild(ctx.renderItem(unit.item));
289
+ return wrapper;
290
+ }
291
+
292
+ function folderMenuEntries(section, ctx) {
293
+ var folders = currentFolderState().folders;
294
+ var at = folders.map(function (f) { return f.id; }).indexOf(section.id);
295
+ var folder = { id: section.id, label: section.label };
296
+ return [
297
+ { label: "New session here", icon: "plus", run: function () { openSessionCreate(section.id); } },
298
+ { label: "Rename", icon: "pencil", run: function () { renameFolder(folder); } },
299
+ { label: "Move up", icon: "arrow-up", disabled: at <= 0, run: function () { shiftFolder(section.id, -1); } },
300
+ { label: "Move down", icon: "arrow-down", disabled: at === -1 || at >= folders.length - 1, run: function () { shiftFolder(section.id, 1); } },
301
+ { label: "Delete folder", icon: "trash-2", danger: true, run: function () { deleteFolder(folder); } },
302
+ ];
303
+ }
304
+
305
+ function renderSection(section, ctx, kind) {
306
+ var isFavorites = kind === "favorites";
307
+ var surface = ctx.surface;
308
+ var root = document.createElement("section");
309
+ root.className = "session-folder session-folder-" + kind + (surface === "mobile" ? " is-mobile" : "") + (section.collapsed ? " collapsed" : "");
310
+ root.dataset.folderId = section.id;
311
+ var bodyId = "session-folder-body-" + surface + "-" + section.id;
312
+
313
+ var header = document.createElement("div");
314
+ header.className = "session-folder-header";
315
+ var toggle = document.createElement("button");
316
+ toggle.type = "button";
317
+ toggle.className = "session-folder-toggle";
318
+ toggle.setAttribute("aria-expanded", String(!section.collapsed));
319
+ toggle.setAttribute("aria-controls", bodyId);
320
+ var icon = isFavorites ? "star" : (kind === "unfiled" ? "inbox" : "folder");
321
+ toggle.innerHTML = '<span class="session-folder-chevron">' + iconHtml("chevron-right") + "</span>" +
322
+ '<span class="session-folder-icon">' + iconHtml(icon) + "</span>" +
323
+ '<span class="session-folder-label">' + escapeHtml(section.label) + "</span>" +
324
+ '<span class="session-folder-count">' + countUnits(section.units) + "</span>";
325
+ toggle.addEventListener("click", function () {
326
+ if (consumeFolderClickSuppress()) return;
327
+ toggleFolderCollapse(root, body, toggle, surface, section.id, function (collapsed) {
328
+ sendFolderOp({ op: "set_collapsed", containerKey: section.id, collapsed: collapsed });
329
+ });
330
+ });
331
+ header.appendChild(toggle);
332
+ // Favorites is a tag view, not a place: new sessions are created in real
333
+ // folders (or Unfiled), so it has no New session button.
334
+ if (!isFavorites) header.appendChild(renderNewSessionButton(section.id, section.label, surface));
335
+
336
+ if (kind === "folder") {
337
+ wireFolderContext(header, toggle, function () { return folderMenuEntries(section, ctx); });
338
+ if (surface === "desktop") wireFolderDrag(header, section.id, section.label);
339
+ }
340
+ root.appendChild(header);
341
+
342
+ // The draft session row sits right under its header and stays visible when the
343
+ // folder itself is collapsed.
344
+ var createRow = renderCreateRow(section.id, section.label, surface);
345
+ if (createRow) root.appendChild(createRow);
346
+
347
+ var body = document.createElement("div");
348
+ body.className = "session-folder-body";
349
+ body.id = bodyId;
350
+ body.setAttribute("role", "group");
351
+ body.setAttribute("aria-label", section.label);
352
+ if (!section.units.length) {
353
+ var empty = document.createElement("div");
354
+ empty.className = "session-folder-empty";
355
+ empty.textContent = isFavorites
356
+ ? (surface === "mobile" ? "Tap the star on a session to add it here." : "Star a session or drag one here to add it.")
357
+ : (kind === "folder" ? "Empty folder" : "Nothing unfiled");
358
+ body.appendChild(empty);
359
+ }
360
+ var view = currentFolderState().view;
361
+ var dateGroups = isFavorites ? null : groupUnitsByDate(section.units, view.sort, view.direction);
362
+ if (dateGroups) {
363
+ for (var g = 0; g < dateGroups.length; g++) {
364
+ var ids = groupRootSessionIds(dateGroups[g].units);
365
+ body.appendChild(renderDateGroupHeader(dateGroups[g].label, section.label, ids, ctx.sessions || [], surface, !!ctx.searching));
366
+ for (var gu = 0; gu < dateGroups[g].units.length; gu++) body.appendChild(renderUnit(dateGroups[g].units[gu], section.id, ctx));
367
+ }
368
+ } else {
369
+ for (var i = 0; i < section.units.length; i++) body.appendChild(renderUnit(section.units[i], section.id, ctx));
370
+ }
371
+ root.appendChild(body);
372
+ restoreFolderCollapse(root, body, toggle, surface, section.id, section.collapsed, !!ctx.searching);
373
+ if (surface === "desktop") wireContainerDrop(root, section.id);
374
+ return root;
375
+ }
376
+
377
+ export function renderFavoritesSection(layout, ctx) {
378
+ return renderSection({ id: FAVORITES_ID, label: "Favorites", units: layout.favorites, collapsed: layout.favoritesCollapsed }, ctx, "favorites");
379
+ }
380
+
381
+ // Appends the non-Favorites part of the layout to `container`.
382
+ export function renderOrdinarySections(layout, ctx, container) {
383
+ // The DOM is replaced on every render; a folder drag in flight is re-previewed on the fresh blocks.
384
+ if (ctx.surface === "desktop") queueMicrotask(reapplyFolderDragPreview);
385
+ // The draft shows where the new folder will appear: after the custom
386
+ // folders and before Unfiled.
387
+ var draft = renderFolderDraft(ctx.surface);
388
+ for (var i = 0; i < layout.sections.length; i++) {
389
+ var section = layout.sections[i];
390
+ if (draft && section.type === "unfiled") { container.appendChild(draft); draft = null; }
391
+ container.appendChild(renderSection(section, ctx, section.type));
392
+ }
393
+ if (draft) container.appendChild(draft);
394
+ }