clay-server 5.0.0-beta.3 → 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.
package/lib/public/app.js CHANGED
@@ -287,6 +287,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
287
287
  sessionFolders: null,
288
288
  sessionFoldersSlug: null,
289
289
  sessionFolderCreate: null,
290
+ sidebarCreationEffects: [],
290
291
  sessionSearch: null,
291
292
  sessionFolderViewMenu: null,
292
293
  sessionFolderLayouts: {},
@@ -316,6 +317,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
316
317
  splitGroupsProjectSlug: null,
317
318
  socketPath: null,
318
319
  pendingHomeProjectSlug: null,
320
+ homeProjectEmptyVisible: false,
319
321
  projectsHubVisible: false,
320
322
  projectsHubMarkup: null,
321
323
  projectsHubList: [],
@@ -10,5 +10,11 @@
10
10
  }
11
11
 
12
12
  :root {
13
+ --font-ui: "Geist", "Twemoji", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
14
+ }
15
+
16
+ /* Keep the bundled Korean face scoped so other CJK scripts use platform fallback. */
17
+ [lang|="ko"] {
13
18
  --font-ui: "Geist", "Noto Sans KR", "Twemoji", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
19
+ font-family: var(--font-ui);
14
20
  }
@@ -224,6 +224,55 @@ body.home-surface-boot-pending #connect-overlay.hidden {
224
224
  height: 14px;
225
225
  }
226
226
 
227
+ .home-project-empty {
228
+ position: absolute;
229
+ inset: 0;
230
+ z-index: 5;
231
+ display: grid;
232
+ place-items: center;
233
+ padding: 40px 24px;
234
+ background: var(--bg);
235
+ }
236
+
237
+ .home-project-empty[hidden] { display: none; }
238
+
239
+ .home-project-empty-content {
240
+ width: min(100%, 420px);
241
+ text-align: center;
242
+ }
243
+
244
+ .home-project-empty-icon {
245
+ display: inline-grid;
246
+ place-items: center;
247
+ width: 48px;
248
+ height: 48px;
249
+ margin-bottom: 18px;
250
+ border: 1px solid var(--border-subtle);
251
+ border-radius: 14px;
252
+ background: rgba(var(--overlay-rgb), 0.04);
253
+ color: var(--text-secondary);
254
+ }
255
+
256
+ .home-project-empty-icon .lucide { width: 20px; height: 20px; }
257
+ .home-project-empty-eyebrow { margin: 0 0 8px; color: var(--text-dimmer); font-size: 10px; font-weight: 650; letter-spacing: 0.13em; }
258
+ .home-project-empty h1 { margin: 0; color: var(--text); font-size: clamp(24px, 4vw, 34px); font-weight: 620; letter-spacing: -0.035em; }
259
+ .home-project-empty-status { max-width: 340px; margin: 12px auto 22px; color: var(--text-muted); font-size: 13px; line-height: 1.55; }
260
+ .home-project-empty button {
261
+ min-height: 40px;
262
+ padding: 8px 16px;
263
+ border: 1px solid var(--accent);
264
+ border-radius: 9px;
265
+ background: var(--accent);
266
+ color: var(--accent-text, #fff);
267
+ font: inherit;
268
+ font-size: 12px;
269
+ font-weight: 620;
270
+ cursor: pointer;
271
+ }
272
+ .home-project-empty button:hover:not(:disabled) { filter: brightness(1.06); }
273
+ .home-project-empty button:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
274
+ .home-project-empty button:disabled { border-color: var(--border-subtle); background: rgba(var(--overlay-rgb), 0.05); color: var(--text-dimmer); cursor: default; }
275
+
227
276
  .home-mate-chat-stage {
228
277
  position: relative;
229
278
  flex: 1 1 auto;
@@ -982,6 +1031,9 @@ body.home-surface-boot-pending #connect-overlay.hidden {
982
1031
  }
983
1032
 
984
1033
  @media (max-width: 768px) {
1034
+ .home-project-empty { padding: 32px 20px; }
1035
+ .home-project-empty button { min-height: 44px; }
1036
+
985
1037
  #home-hub:not(.dock-split):not(.dock-focus) .home-mate-chat-stage {
986
1038
  padding-top: 52px;
987
1039
  }
@@ -37,6 +37,13 @@
37
37
  .session-folder-flat { margin-top: 4px; }
38
38
  .session-folder-flat .session-folder-body { border: 0; }
39
39
  .session-folder-empty { padding: 5px 4px 6px; font-size: 10.5px; line-height: 1.4; color: var(--text-muted); opacity: 0.78; }
40
+ .session-folder-body > .session-date-group-header { margin: 7px 4px 2px 6px; min-height: 20px; }
41
+ .session-folder-body > .session-date-group-header:first-child { margin-top: 3px; }
42
+ .session-date-group-count { margin-left: auto; color: var(--text-dimmer); font-size: 9.5px; font-variant-numeric: tabular-nums; }
43
+ .session-date-group-header .session-group-clear-btn { margin-left: 5px; height: 18px; padding-inline: 7px; }
44
+ .session-date-group-header .session-group-clear-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
45
+ .session-date-group-header.is-mobile { min-height: 44px; margin-top: 4px; font-size: 11px; }
46
+ .session-date-group-header.is-mobile .session-group-clear-btn { min-width: 44px; min-height: 44px; font-size: 11px; }
40
47
 
41
48
  .session-folder-unit { position: relative; border-radius: 8px; }
42
49
  .session-folder-unit.dragging, .session-folder-header.dragging { opacity: 0.45; }
@@ -376,8 +383,20 @@
376
383
  .session-folder-new-btn { height: 34px; min-width: 44px; }
377
384
  .session-create-row.is-mobile .session-create-picker, .session-create-row.is-mobile .session-create-icon-btn, .session-create-row.is-mobile .session-create-menu-item { min-height: 40px; }
378
385
  }
386
+
387
+ /* A successful local creation keeps its normal geometry and readability while
388
+ the restrained production Thinking sheen crosses the title twice. */
389
+ .sidebar-creation-shimmer {
390
+ --creation-shimmer-base: var(--text-secondary);
391
+ background-image: linear-gradient(100deg, var(--creation-shimmer-base) 40%, var(--text-dimmer) 50%, var(--creation-shimmer-base) 60%);
392
+ background-repeat: no-repeat; background-size: 300% 100%; background-position: 100% 0;
393
+ background-clip: text; -webkit-background-clip: text; color: transparent; -webkit-text-fill-color: transparent;
394
+ animation: thinking-summary-shimmer 2.8s ease-in-out 2 both;
395
+ }
396
+ .session-folder-label.sidebar-creation-shimmer { --creation-shimmer-base: var(--text-muted); }
379
397
  @media (prefers-reduced-motion: reduce) {
380
398
  .session-folder-new-btn, .session-folder-new-label { transition: none; }
381
399
  .session-folder-new-btn.is-label-changing .session-folder-new-label-text,
382
400
  .session-folder-new-btn.is-label-changing .session-folder-new-label-previous { animation: none; }
401
+ .sidebar-creation-shimmer { animation: none; background: none; color: inherit; -webkit-text-fill-color: currentColor; }
383
402
  }
@@ -137,7 +137,16 @@
137
137
  </aside>
138
138
  <div id="home-workspace" class="home-workspace">
139
139
  <button id="home-sidebar-expand" class="home-sidebar-expand" type="button" title="Show sidebar" aria-label="Show Home sidebar" aria-describedby="home-sidebar-expand-brand-label"><i data-lucide="panel-left-open"></i><span id="home-sidebar-expand-brand-label" class="home-sidebar-brand-wordmark home-sidebar-expand-wordmark">Clay Studio</span></button>
140
- <button id="home-close-control" class="home-close-control" type="button" title="Projects" aria-label="Projects"><i data-lucide="folder" aria-hidden="true"></i><span>Projects</span></button>
140
+ <button id="home-close-control" class="home-close-control" type="button" title="Return to project" aria-label="Return to project"><i data-lucide="folder" aria-hidden="true"></i><span>Project</span></button>
141
+ <section id="home-project-empty" class="home-project-empty" aria-labelledby="home-project-empty-title" hidden>
142
+ <div class="home-project-empty-content">
143
+ <span class="home-project-empty-icon" aria-hidden="true"><i data-lucide="folder-plus"></i></span>
144
+ <p class="home-project-empty-eyebrow">PROJECT WORKSPACE</p>
145
+ <h1 id="home-project-empty-title" tabindex="-1">Start a new project</h1>
146
+ <p id="home-project-empty-status" class="home-project-empty-status" role="status">Create an empty project, then continue in its workspace.</p>
147
+ <button id="home-project-empty-create" type="button">Start a new project</button>
148
+ </div>
149
+ </section>
141
150
  <div id="home-conversation-region" class="home-conversation-region">
142
151
  <div id="home-mate-chat" class="home-mate-chat">
143
152
  <div class="home-mate-chat-stage">
@@ -3,8 +3,8 @@
3
3
  import { showProjectsHub, hideProjectsHub } from './projects-hub.js';
4
4
  import { store } from './store.js';
5
5
  import { getWs } from './ws-ref.js';
6
- import { getCachedProjects, switchProject } from './app-projects.js';
7
- import { chooseProjectActivationTarget } from './project-activation.js';
6
+ import { getCachedProjects, openAddProjectModal, switchProject } from './app-projects.js';
7
+ import { chooseProjectActivationTarget, homeProjectCreationState } from './project-activation.js';
8
8
  import { mateAvatarUrl } from './avatar.js';
9
9
  import { exitDmMode } from './app-dm.js';
10
10
  import { closeHomeDock, initHomeDock, renderDock, requestHomeDockPreference } from './home-dock.js';
@@ -22,6 +22,7 @@ import { disposeHomeSessionActions } from './home-session-actions.js';
22
22
  import { initHomeDebatesArchive, restoreHomeDebatesArchive } from './home-debates-archive.js';
23
23
  import { isHomeDebatesSurface } from './home-sub-surface.js';
24
24
  import { HOME_CAPSULE_CREATION_EVENT } from './home-capsule-creation-intent.js';
25
+ import { bindHomeProjectEmptyDismissal, setHomeProjectEmptyCoverage } from './home-project-empty-access.js';
25
26
 
26
27
  var homeHub = null;
27
28
  var homeHubVisible = false;
@@ -44,6 +45,13 @@ function syncHomeCloseControl() {
44
45
  var close = document.getElementById("home-close-control");
45
46
  if (!close) return;
46
47
  close.classList.remove("hidden");
48
+ var loaded = store.get('projectListLoaded') === true;
49
+ var slug = loaded ? getHomeReturnSlug() : null;
50
+ var label = close.querySelector("span");
51
+ close.disabled = !loaded;
52
+ close.title = !loaded ? "Loading projects" : (slug ? "Return to project" : "Project options");
53
+ close.setAttribute("aria-label", close.title);
54
+ if (label) label.textContent = loaded ? "Project" : "Loading…";
47
55
  }
48
56
 
49
57
  function getHomeReturnSlug() {
@@ -53,6 +61,34 @@ function getHomeReturnSlug() {
53
61
  );
54
62
  }
55
63
 
64
+ function syncHomeProjectEmptyState() {
65
+ var empty = document.getElementById("home-project-empty");
66
+ if (!empty) return;
67
+ var requested = store.get('homeProjectEmptyVisible') === true;
68
+ var loaded = store.get('projectListLoaded') === true;
69
+ var returnSlug = loaded ? getHomeReturnSlug() : null;
70
+ var visible = homeHubVisible && requested && loaded && !returnSlug;
71
+ setHomeProjectEmptyCoverage(document, visible);
72
+ empty.hidden = !visible;
73
+ if (requested && loaded && returnSlug) store.set({ homeProjectEmptyVisible: false });
74
+ if (!visible) return;
75
+ var availability = homeProjectCreationState(store.snap());
76
+ var action = document.getElementById("home-project-empty-create");
77
+ var status = document.getElementById("home-project-empty-status");
78
+ action.disabled = !availability.enabled;
79
+ if (status) status.textContent = availability.message;
80
+ }
81
+
82
+ function dismissHomeProjectEmpty() {
83
+ if (store.get('homeProjectEmptyVisible')) store.set({ homeProjectEmptyVisible: false });
84
+ }
85
+
86
+ function openHomeProjectCreation() {
87
+ var availability = homeProjectCreationState(store.snap());
88
+ if (!availability.enabled || getHomeReturnSlug()) return;
89
+ openAddProjectModal("create");
90
+ }
91
+
56
92
  function getVisibleMates() {
57
93
  var mates = (store.get('cachedMatesList') || []).filter(function (mate) {
58
94
  return !!mate && !mate.archived;
@@ -100,6 +136,7 @@ function isMateBusy(mate, projectStatus, mentionActive) {
100
136
  }
101
137
 
102
138
  function selectHomeMate(mateId) {
139
+ if (store.get('homeProjectEmptyVisible')) store.set({ homeProjectEmptyVisible: false });
103
140
  if (mateId !== store.get('homeChatMateId') || isHomeDebatesSurface()) openHomeChat(mateId);
104
141
  closeHomeSidebarAfterSelection();
105
142
  }
@@ -246,14 +283,20 @@ export function initHomeHub() {
246
283
  initHomeDock();
247
284
  initHomeDebatesArchive();
248
285
  document.getElementById("home-mate-list").addEventListener("keydown", handleMateListKeydown);
249
- document.getElementById("home-close-control").addEventListener("click", function () { showProjectsHub(); });
286
+ bindHomeProjectEmptyDismissal(document.getElementById("home-sidebar"), dismissHomeProjectEmpty);
287
+ document.getElementById("home-close-control").addEventListener("click", minimizeHomeHub);
288
+ document.getElementById("home-project-empty-create").addEventListener("click", openHomeProjectCreation);
250
289
  document.getElementById("home-nav-projects").addEventListener("click", function () { showProjectsHub(); });
251
290
  window.addEventListener("clay:home-debate", function () { hideHomeHub(); });
252
291
  document.addEventListener("visibilitychange", syncHomePresentation);
253
292
 
254
293
  store.subscribe(function (state, prev) {
255
294
  if (state.connected !== prev.connected && state.connected && homeHubVisible) syncHomePresentation();
256
- if (state.currentSlug !== prev.currentSlug) syncHomeCloseControl();
295
+ if (state.currentSlug !== prev.currentSlug || state.homeSurfaceProjectSlug !== prev.homeSurfaceProjectSlug ||
296
+ state.projectListLoaded !== prev.projectListLoaded || state.projectsHubList !== prev.projectsHubList) syncHomeCloseControl();
297
+ if (state.homeProjectEmptyVisible !== prev.homeProjectEmptyVisible || state.projectListLoaded !== prev.projectListLoaded ||
298
+ state.projectsHubList !== prev.projectsHubList || state.projectsAccessLoaded !== prev.projectsAccessLoaded ||
299
+ state.permissions !== prev.permissions || state.connected !== prev.connected) syncHomeProjectEmptyState();
257
300
  if (state.homeChatMateId !== prev.homeChatMateId || state.cachedMatesList !== prev.cachedMatesList) renderHomeMateSwitcher();
258
301
  if (state.homeSidebarCollapsed !== prev.homeSidebarCollapsed && !state.homeSidebarCollapsed) {
259
302
  requestAnimationFrame(renderHomeMateSwitcher);
@@ -352,7 +395,7 @@ export function updateHomeIconBadge() {
352
395
 
353
396
  export function showHomeHub(fromHistory) {
354
397
  hideProjectsHub();
355
- store.set({ pendingHomeProjectSlug: null });
398
+ store.set({ pendingHomeProjectSlug: null, homeProjectEmptyVisible: false });
356
399
  if (store.get('dmMode')) exitDmMode();
357
400
  var activeMateId = store.get('homeChatMateId');
358
401
  var resume = homeHubSuspended || homeHubVisible;
@@ -363,6 +406,7 @@ export function showHomeHub(fromHistory) {
363
406
  rememberHomePrimarySurface("home");
364
407
  syncHomePresentation();
365
408
  syncHomeCloseControl();
409
+ syncHomeProjectEmptyState();
366
410
  renderHomeMateSwitcher();
367
411
  if (activeMateId && !resume && !isHomeDebatesSurface()) openHomeChat(activeMateId);
368
412
  if (isHomeDebatesSurface()) restoreHomeDebatesArchive();
@@ -392,6 +436,7 @@ export function hideHomeHub() {
392
436
  disposeHomeSessionActions();
393
437
  homeHubVisible = false;
394
438
  homeHubSuspended = true;
439
+ if (store.get('homeProjectEmptyVisible')) store.set({ homeProjectEmptyVisible: false });
395
440
  hideHomeShell();
396
441
  homeHub.classList.add("hidden");
397
442
  syncHomePresentation();
@@ -402,9 +447,17 @@ export function hideHomeHub() {
402
447
  }
403
448
 
404
449
  export function minimizeHomeHub() {
405
- var slug = getHomeReturnSlug();
406
450
  if (!homeHubVisible) return;
407
- if (!slug) { showProjectsHub(); return; }
451
+ if (store.get('projectListLoaded') !== true) return;
452
+ var slug = getHomeReturnSlug();
453
+ if (!slug) {
454
+ store.set({ homeProjectEmptyVisible: true });
455
+ syncHomeProjectEmptyState();
456
+ var title = document.getElementById("home-project-empty-title");
457
+ if (title) title.focus({ preventScroll: true });
458
+ return;
459
+ }
460
+ if (store.get('homeProjectEmptyVisible')) store.set({ homeProjectEmptyVisible: false });
408
461
  switchProject(slug);
409
462
  var projectInput = document.getElementById("input");
410
463
  if (projectInput && !projectInput.disabled) projectInput.focus({ preventScroll: true });
@@ -0,0 +1,44 @@
1
+ // Accessibility boundary for the Home no-project overlay.
2
+
3
+ var coveredIds = ["home-conversation-region", "home-dock-backdrop", "home-dock-divider", "home-tool-workbench"];
4
+ var priorStates = new WeakMap();
5
+
6
+ function remember(element) {
7
+ if (priorStates.has(element)) return;
8
+ priorStates.set(element, {
9
+ inert: element.inert === true,
10
+ inertAttribute: element.hasAttribute("inert"),
11
+ ariaHidden: element.getAttribute("aria-hidden"),
12
+ });
13
+ }
14
+
15
+ function restore(element) {
16
+ var prior = priorStates.get(element);
17
+ if (!prior) return;
18
+ element.inert = prior.inert;
19
+ if (prior.inertAttribute) element.setAttribute("inert", "");
20
+ else element.removeAttribute("inert");
21
+ if (prior.ariaHidden === null) element.removeAttribute("aria-hidden");
22
+ else element.setAttribute("aria-hidden", prior.ariaHidden);
23
+ priorStates.delete(element);
24
+ }
25
+
26
+ export function setHomeProjectEmptyCoverage(doc, covered) {
27
+ for (var i = 0; i < coveredIds.length; i++) {
28
+ var element = doc.getElementById(coveredIds[i]);
29
+ if (!element) continue;
30
+ if (!covered) { restore(element); continue; }
31
+ remember(element);
32
+ element.inert = true;
33
+ element.setAttribute("inert", "");
34
+ element.setAttribute("aria-hidden", "true");
35
+ }
36
+ }
37
+
38
+ export function bindHomeProjectEmptyDismissal(sidebar, dismiss) {
39
+ sidebar.addEventListener("click", function (event) {
40
+ var control = event.target.closest("#home-sidebar-new, #home-sidebar-all, #home-sidebar-debate, #home-tools-btn, .home-mate-list-row, .home-sidebar-recent-list button");
41
+ if (!control || control.disabled) return;
42
+ dismiss();
43
+ }, true);
44
+ }
@@ -19,6 +19,17 @@ export function chooseProjectActivationTarget(projects, candidates) {
19
19
  return null;
20
20
  }
21
21
 
22
+ export function homeProjectCreationState(state) {
23
+ var current = state || {};
24
+ if (current.projectListLoaded !== true) return { ready: false, enabled: false, message: "Loading projects…" };
25
+ if (current.projectsAccessLoaded !== true) return { ready: false, enabled: false, message: "Checking project access…" };
26
+ if (current.permissions && current.permissions.createProject !== true) {
27
+ return { ready: true, enabled: false, message: "Ask an administrator to create or share a project with you." };
28
+ }
29
+ if (current.connected !== true) return { ready: true, enabled: false, message: "Reconnect to start a project." };
30
+ return { ready: true, enabled: true, message: "Create an empty project, then continue in its workspace." };
31
+ }
32
+
22
33
  export function isProjectActivationPending(state, slug, socket) {
23
34
  var target = projectWsPath(slug);
24
35
  return !!target
@@ -17,6 +17,7 @@ import { getWs } from './ws-ref.js';
17
17
  import { showToast } from './utils.js';
18
18
  import { resolvePreferredVendor } from './default-vendor.js';
19
19
  import { sendFolderOp, currentFolderState } from './session-folders.js';
20
+ import { queueSidebarCreationEffect } from './sidebar-creation-effect.js';
20
21
 
21
22
  // Ids are unique without any module-level counter.
22
23
  function nextId(prefix) {
@@ -207,6 +208,7 @@ export function handleNewSessionResult(msg) {
207
208
  var destination = msg.folderPlaced === false ? "unfiled" : (msg.folderId || form.folderId || "unfiled");
208
209
  if (msg.folderPlaced === false) showToast("The session was created but could not be filed in that folder. It is in Unfiled.", "warn");
209
210
  store.set({ sessionCreate: null });
211
+ queueSidebarCreationEffect('session', msg.requestId, msg.sessionId, destination);
210
212
  revealCreatedSession(destination, msg.sessionId);
211
213
  return true;
212
214
  }
@@ -0,0 +1,57 @@
1
+ import { store } from './store.js';
2
+ import { getWs } from './ws-ref.js';
3
+ import { showConfirm } from './app-misc.js';
4
+ import { describeGroupDelete } from './session-delete-scope.js';
5
+ import { showToast } from './utils.js';
6
+
7
+ function confirmClear(label, ids, sessions) {
8
+ if (!ids.length) return;
9
+ var requestedIds = ids.slice();
10
+ var originSlug = store.get('currentSlug');
11
+ var originWs = getWs();
12
+ showConfirm(describeGroupDelete(label, requestedIds, sessions).message, function () {
13
+ var ws = getWs();
14
+ var permissions = store.get('permissions');
15
+ var valid = store.get('currentSlug') === originSlug && ws === originWs && ws && ws.readyState === 1 &&
16
+ store.get('connected') && (!permissions || permissions.sessionDelete !== false);
17
+ if (!valid) {
18
+ showToast("Clear cancelled because the project, connection, or delete permission changed.", "warn");
19
+ return;
20
+ }
21
+ ws.send(JSON.stringify({ type: "bulk_delete_sessions", sessionIds: requestedIds }));
22
+ });
23
+ }
24
+
25
+ export function renderDateGroupHeader(dateLabel, folderLabel, ids, sessions, surface, searching) {
26
+ var header = document.createElement("div");
27
+ header.className = "session-group-header session-date-group-header" + (surface === "mobile" ? " is-mobile" : "");
28
+ header.setAttribute("role", "heading");
29
+ header.setAttribute("aria-level", "3");
30
+
31
+ var label = document.createElement("span");
32
+ label.className = "session-group-header-label";
33
+ label.textContent = dateLabel;
34
+ header.appendChild(label);
35
+
36
+ var count = document.createElement("span");
37
+ count.className = "session-date-group-count";
38
+ count.textContent = String(ids.length);
39
+ count.setAttribute("aria-label", ids.length + (ids.length === 1 ? " session" : " sessions"));
40
+ header.appendChild(count);
41
+
42
+ if ((!store.get('permissions') || store.get('permissions').sessionDelete !== false) && ids.length) {
43
+ var clear = document.createElement("button");
44
+ clear.type = "button";
45
+ clear.className = "session-group-clear-btn";
46
+ clear.textContent = "Clear";
47
+ clear.setAttribute("aria-label", "Clear " + ids.length + " " + (searching ? "matching " : "") + (ids.length === 1 ? "session" : "sessions") + " from " + dateLabel + " in " + folderLabel);
48
+ clear.addEventListener("click", function (event) {
49
+ event.preventDefault();
50
+ event.stopPropagation();
51
+ var scope = dateLabel + " in " + folderLabel + (searching ? " (matching search)" : "");
52
+ confirmClear(scope, ids, sessions);
53
+ });
54
+ header.appendChild(clear);
55
+ }
56
+ return header;
57
+ }
@@ -0,0 +1,84 @@
1
+ // Pure date subsection projection for units inside a real folder. Calendar
2
+ // boundaries are local-time Date constructors, so midnight and week/month
3
+ // transitions remain correct across DST changes.
4
+
5
+ var LABELS = ["Today", "Yesterday", "This week", "Earlier this month", "Older"];
6
+
7
+ export function unitTimestamp(unit, sort) {
8
+ var value = sort === "created" ? unit.createdAt : unit.lastActivity;
9
+ if (typeof value === "number" && Number.isFinite(value)) return value;
10
+ var parsed = value ? new Date(value).getTime() : 0;
11
+ return Number.isFinite(parsed) ? parsed : 0;
12
+ }
13
+
14
+ export function localDateBoundaries(nowValue) {
15
+ var now = nowValue instanceof Date ? nowValue : new Date(nowValue === undefined ? Date.now() : nowValue);
16
+ var year = now.getFullYear();
17
+ var month = now.getMonth();
18
+ var day = now.getDate();
19
+ var today = new Date(year, month, day).getTime();
20
+ var yesterday = new Date(year, month, day - 1).getTime();
21
+ var mondayOffset = (now.getDay() + 6) % 7;
22
+ var week = new Date(year, month, day - mondayOffset).getTime();
23
+ var monthStart = new Date(year, month, 1).getTime();
24
+ return { today: today, yesterday: yesterday, week: week, month: monthStart };
25
+ }
26
+
27
+ export function dateBucket(timestamp, boundaries) {
28
+ if (timestamp >= boundaries.today) return "Today";
29
+ if (timestamp >= boundaries.yesterday) return "Yesterday";
30
+ if (timestamp >= boundaries.week) return "This week";
31
+ if (timestamp >= boundaries.month) return "Earlier this month";
32
+ return "Older";
33
+ }
34
+
35
+ export function groupUnitsByDate(units, sort, direction, nowValue) {
36
+ if (sort !== "activity" && sort !== "created") return null;
37
+ var source = Array.isArray(units) ? units : [];
38
+ var decorated = source.map(function (unit, index) {
39
+ return { unit: unit, index: index, timestamp: unitTimestamp(unit, sort) };
40
+ });
41
+ var sign = direction === "asc" ? 1 : -1;
42
+ decorated.sort(function (a, b) {
43
+ var delta = (a.timestamp - b.timestamp) * sign;
44
+ return delta || a.index - b.index;
45
+ });
46
+ var boundaries = localDateBoundaries(nowValue);
47
+ var byLabel = {};
48
+ for (var i = 0; i < decorated.length; i++) {
49
+ var label = dateBucket(decorated[i].timestamp, boundaries);
50
+ if (!byLabel[label]) byLabel[label] = [];
51
+ byLabel[label].push(decorated[i].unit);
52
+ }
53
+ var order = direction === "asc" ? LABELS.slice().reverse() : LABELS;
54
+ var groups = [];
55
+ for (var j = 0; j < order.length; j++) {
56
+ if (byLabel[order[j]]) groups.push({ label: order[j], units: byLabel[order[j]] });
57
+ }
58
+ return groups;
59
+ }
60
+
61
+ export function unitRootSessionIds(unit) {
62
+ var item = unit && unit.item;
63
+ if (!item) return [];
64
+ if (item.type === "session" && item.data && typeof item.data.id === "number") return [item.data.id];
65
+ if (item.type === "driver-hierarchy" && item.root && item.root.driver && typeof item.root.driver.id === "number") return [item.root.driver.id];
66
+ if (item.type === "orphan-workers" && Array.isArray(item.workers)) return item.workers.map(function (session) { return session.id; });
67
+ if (item.type === "split-group" && Array.isArray(item.members)) return item.members.map(function (session) { return session.id; });
68
+ if (item.type === "loop" && Array.isArray(item.children)) return item.children.map(function (session) { return session.id; });
69
+ return [];
70
+ }
71
+
72
+ export function groupRootSessionIds(units) {
73
+ var result = [];
74
+ var seen = {};
75
+ for (var i = 0; i < units.length; i++) {
76
+ var ids = unitRootSessionIds(units[i]);
77
+ for (var j = 0; j < ids.length; j++) {
78
+ if (typeof ids[j] !== "number" || seen[ids[j]]) continue;
79
+ seen[ids[j]] = true;
80
+ result.push(ids[j]);
81
+ }
82
+ }
83
+ return result;
84
+ }
@@ -11,6 +11,7 @@ import { getWs } from './ws-ref.js';
11
11
  import { escapeHtml } from './utils.js';
12
12
  import { currentFolderState } from './session-folders.js';
13
13
  import { createModal, button, closeFolderModal } from './session-folder-dialogs.js';
14
+ import { spawnDustParticles, clearDustParticles } from './sidebar.js';
14
15
 
15
16
  // The dialog's elements are found through the modal recorded in the store, so
16
17
  // nothing here outlives the dialog: closing it drops the store state and the
@@ -52,6 +53,27 @@ function phrase(count, noun) {
52
53
  return count + " " + noun + (count === 1 ? "" : "s");
53
54
  }
54
55
 
56
+ function removalOrigin(folderId) {
57
+ var sections = document.querySelectorAll('.session-folder[data-folder-id]');
58
+ for (var i = 0; i < sections.length; i++) {
59
+ if (sections[i].dataset.folderId !== String(folderId) || sections[i].classList.contains('session-folder-favorites') || sections[i].classList.contains('session-folder-unfiled')) continue;
60
+ var header = sections[i].querySelector('.session-folder-header');
61
+ if (!header || !header.getClientRects().length) continue;
62
+ var icon = header.querySelector('.session-folder-icon') || header;
63
+ var rect = icon.getBoundingClientRect();
64
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, slug: store.get('currentSlug') };
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function playRemoval(origin) {
70
+ if (!origin || origin.slug !== store.get('currentSlug')) return;
71
+ spawnDustParticles(origin.x, origin.y, {
72
+ count: 12, minDistance: 18, distanceRange: 34, upwardBias: 10,
73
+ minDuration: 480, durationRange: 340, containerClass: "folder-delete-dust", maxContainers: 3,
74
+ });
75
+ }
76
+
55
77
  // Other folders a move can target: every custom folder except the source, and
56
78
  // Unfiled is the first choice, not a destination here, and Favorites is a tag,
57
79
  // never a destination.
@@ -136,7 +158,7 @@ function submit() {
136
158
  var message = { type: "session_folders_delete", folderId: current.folderId, mode: current.preview.sessionCount === 0 ? "unfiled" : current.mode, token: current.preview.token, requestId: requestId };
137
159
  if (message.mode === "move") message.destinationId = current.destinationId;
138
160
  if (message.mode === "delete") message.confirmDelete = true;
139
- patch({ phase: "pending", requestId: requestId, error: "" });
161
+ patch({ phase: "pending", requestId: requestId, error: "", removalOrigin: removalOrigin(current.folderId) });
140
162
  if (!send(message)) patch({ phase: "ready", requestId: current.requestId, error: "Not connected. Try again." });
141
163
  }
142
164
 
@@ -295,7 +317,10 @@ export function handleFolderDeleteState(msg) {
295
317
  patch({ phase: current.preview ? "ready" : "error", error: msg.error, confirmDelete: false });
296
318
  return true;
297
319
  }
298
- if (msg.folderDeleted === current.folderId) closeFolderModal();
320
+ if (msg.folderDeleted === current.folderId) {
321
+ playRemoval(current.removalOrigin);
322
+ closeFolderModal();
323
+ }
299
324
  return true;
300
325
  }
301
326
 
@@ -304,6 +329,7 @@ store.subscribe(function (next, previous) {
304
329
  // Retry depends on the connection, so a change in it repaints the dialog.
305
330
  else if (next.connected !== previous.connected && next.sessionFolderDelete) paint();
306
331
  var current = next.sessionFolderDelete;
332
+ if (next.currentSlug !== previous.currentSlug || next.dmMode !== previous.dmMode) clearDustParticles("folder-delete-dust");
307
333
  if (!current || !previous.connected || next.connected) return;
308
334
  if (current.phase === "pending") {
309
335
  patch({ phase: "ready", error: "Connection lost. Check the folder before trying again." });
@@ -8,6 +8,7 @@ import { store } from './store.js';
8
8
  import { getWs } from './ws-ref.js';
9
9
  import { showToast } from './utils.js';
10
10
  import { defaultFolderState } from './session-folder-layout.js';
11
+ import { queueSidebarCreationEffect } from './sidebar-creation-effect.js';
11
12
 
12
13
  export function handleSessionFoldersState(msg) {
13
14
  var slug = store.get('currentSlug');
@@ -26,6 +27,7 @@ export function handleSessionFoldersState(msg) {
26
27
  return;
27
28
  }
28
29
  store.set({ sessionFolderCreate: null });
30
+ if (msg.folderId) queueSidebarCreationEffect('folder', msg.requestId, msg.folderId, null);
29
31
  if (create.sessionId !== null && msg.folderId) sendFolderOp({ op: "place_session", sessionId: create.sessionId, folderId: msg.folderId });
30
32
  }
31
33
 
@@ -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
+ });
@@ -744,6 +744,7 @@ export function renderMobileSessionsInto(container) {
744
744
  var folderCtx = {
745
745
  surface: "mobile",
746
746
  searching: matchIds !== null,
747
+ sessions: getCachedSessions(),
747
748
  renderItem: function (it) {
748
749
  if (it.type === "loop") return createMobileLoopGroup(it.loopId, it.children, it.groupKey);
749
750
  if (it.type === "driver-hierarchy") return renderMobileDriverHierarchy(it.root, createMobileSessionItem, refreshMobileChatSheet);
@@ -20,6 +20,8 @@ import { openFolderDelete } from './session-folder-delete.js';
20
20
  import { openSessionCreate } from './session-create.js';
21
21
  import { renderNewSessionButton, renderCreateRow } from './session-create-form.js';
22
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';
23
25
 
24
26
  // Mutable UI state (open menu, drag in flight, the layout each surface last
25
27
  // rendered) lives in the store; this module only owns the functions.
@@ -65,7 +67,7 @@ export function unitsFromItems(items) {
65
67
  key: data && typeof data.id === "number" ? data.id : null,
66
68
  title: data ? data.title : (item.type === "split-group" ? (item.group && item.group.name) : (item.type === "loop" ? item.loopId : "")),
67
69
  createdAt: data ? data.createdAt || 0 : 0,
68
- lastActivity: item.lastActivity || 0,
70
+ lastActivity: data ? (data.lastActivity || 0) : (item.lastActivity || 0),
69
71
  item: item,
70
72
  });
71
73
  }
@@ -355,7 +357,17 @@ function renderSection(section, ctx, kind) {
355
357
  : (kind === "folder" ? "Empty folder" : "Nothing unfiled");
356
358
  body.appendChild(empty);
357
359
  }
358
- for (var i = 0; i < section.units.length; i++) body.appendChild(renderUnit(section.units[i], section.id, ctx));
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
+ }
359
371
  root.appendChild(body);
360
372
  restoreFolderCollapse(root, body, toggle, surface, section.id, section.collapsed, !!ctx.searching);
361
373
  if (surface === "desktop") wireContainerDrop(root, section.id);
@@ -879,7 +879,7 @@ function renderSessionItem(s, options) {
879
879
  if (store.get('isMultiUserMode') && s.sessionVisibility === "shared") {
880
880
  textHtml += '<span class="session-shared-icon" title="Shared session" aria-label="Shared session">' + iconHtml("users") + '</span>';
881
881
  }
882
- textHtml += highlightMatch(s.title || "New Session", getListSearchQuery());
882
+ textHtml += '<span class="session-item-title">' + highlightMatch(s.title || "New Session", getListSearchQuery()) + '</span>';
883
883
  textSpan.innerHTML = textHtml;
884
884
  el.appendChild(textSpan);
885
885
  appendGithubWork(el, s, textSpan);
@@ -1118,6 +1118,7 @@ export function renderSessionList(sessions) {
1118
1118
  var folderCtx = {
1119
1119
  surface: "desktop",
1120
1120
  searching: matchIds !== null,
1121
+ sessions: cachedSessions,
1121
1122
  renderItem: function (it) {
1122
1123
  if (it.type === "loop") return renderLoopGroup(it.loopId, it.children, it.groupKey) || document.createElement("div");
1123
1124
  if (it.type === "split-group") return renderSplitGroupItem(it.group, it.members);
@@ -460,10 +460,24 @@ export function initSidebar(_ctx) {
460
460
 
461
461
  }
462
462
 
463
- export function spawnDustParticles(cx, cy) {
463
+ export function clearDustParticles(containerClass) {
464
+ var selector = ".dust-particle-container" + (containerClass ? "." + containerClass : "");
465
+ var containers = document.querySelectorAll(selector);
466
+ for (var i = 0; i < containers.length; i++) containers[i].remove();
467
+ }
468
+
469
+ export function spawnDustParticles(cx, cy, options) {
470
+ var opts = options || {};
471
+ if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return null;
464
472
  var colors = ["#8B7355", "#A0522D", "#D2B48C", "#C4A882", "#9E9E9E", "#B8860B", "#BC8F8F"];
465
- var count = 24;
473
+ var count = Math.max(1, Math.min(opts.count || 24, 24));
474
+ var containerClass = opts.containerClass || "";
475
+ var maxContainers = Math.max(1, Math.min(opts.maxContainers || 6, 6));
476
+ var active = document.querySelectorAll(".dust-particle-container" + (containerClass ? "." + containerClass : ""));
477
+ for (var ai = 0; ai <= active.length - maxContainers; ai++) active[ai].remove();
466
478
  var container = document.createElement("div");
479
+ container.className = "dust-particle-container" + (containerClass ? " " + containerClass : "");
480
+ container.setAttribute("aria-hidden", "true");
467
481
  container.style.position = "fixed";
468
482
  container.style.top = "0";
469
483
  container.style.left = "0";
@@ -478,10 +492,10 @@ export function spawnDustParticles(cx, cy) {
478
492
  dot.className = "dust-particle";
479
493
  var size = 3 + Math.random() * 5;
480
494
  var angle = Math.random() * Math.PI * 2;
481
- var dist = 30 + Math.random() * 60;
495
+ var dist = (opts.minDistance || 30) + Math.random() * (opts.distanceRange || 60);
482
496
  var dx = Math.cos(angle) * dist;
483
- var dy = Math.sin(angle) * dist - 20; // bias upward
484
- var duration = 600 + Math.random() * 500;
497
+ var dy = Math.sin(angle) * dist - (opts.upwardBias === undefined ? 20 : opts.upwardBias);
498
+ var duration = (opts.minDuration || 600) + Math.random() * (opts.durationRange || 500);
485
499
 
486
500
  dot.style.width = size + "px";
487
501
  dot.style.height = size + "px";
@@ -496,4 +510,5 @@ export function spawnDustParticles(cx, cy) {
496
510
  }
497
511
 
498
512
  setTimeout(function () { container.remove(); }, 1200);
513
+ return container;
499
514
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "5.0.0-beta.3",
3
+ "version": "5.0.0-beta.4",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js"