clay-server 5.0.1 → 5.0.2-beta.1

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.
@@ -30,6 +30,22 @@
30
30
  background: rgba(var(--overlay-rgb), 0.05); color: var(--text-dimmer); font-size: 9.5px; font-weight: 600;
31
31
  text-align: center; font-variant-numeric: tabular-nums;
32
32
  }
33
+ .session-count-summary { position: relative; }
34
+ .session-count-summary::after {
35
+ content: attr(data-count-detail); position: absolute; right: -4px; bottom: calc(100% + 6px); z-index: 20;
36
+ width: max-content; max-width: min(220px, calc(100vw - 24px)); padding: 5px 7px; border: 1px solid var(--border);
37
+ border-radius: 7px; background: var(--bg-alt, var(--sidebar-bg)); color: var(--text); box-shadow: 0 4px 14px rgba(var(--shadow-rgb), 0.3);
38
+ font-size: 10.5px; font-weight: 500; line-height: 1.25; letter-spacing: 0; white-space: nowrap;
39
+ opacity: 0; visibility: hidden; transform: translateY(2px); pointer-events: none;
40
+ transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s;
41
+ }
42
+ .session-folder-toggle:hover .session-count-summary::after,
43
+ .session-folder-toggle:focus-visible .session-count-summary::after,
44
+ .session-date-group-count:hover::after, .session-date-group-count:focus-visible::after,
45
+ .session-search-count:hover::after, .session-search-count:focus-visible::after {
46
+ opacity: 1; visibility: visible; transform: translateY(0);
47
+ }
48
+ .session-date-group-count:focus-visible, .session-search-count:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
33
49
 
34
50
  .session-folder-body[hidden] { display: none; }
35
51
  .session-folder-body { padding-left: 4px; border-left: 1px solid var(--border-subtle, var(--border)); margin-left: 7px; }
@@ -84,7 +100,7 @@
84
100
  .session-search-count {
85
101
  position: absolute; right: 30px; top: 50%; transform: translateY(-50%); min-width: 16px; padding: 0 5px; height: 15px; line-height: 15px;
86
102
  border-radius: 999px; background: rgba(var(--overlay-rgb), 0.08); color: var(--text-dimmer); font-size: 9.5px; font-weight: 600; text-align: center;
87
- pointer-events: none;
103
+ cursor: help;
88
104
  }
89
105
  .session-search-count:empty { display: none; }
90
106
  .session-search-clear {
@@ -162,8 +178,9 @@
162
178
  .session-folder-tool svg { width: 12px; height: 12px; display: block; }
163
179
  .session-folder-tool:hover { color: var(--text); background: rgba(var(--overlay-rgb), 0.08); }
164
180
 
165
- /* The Favorites star: unfilled and revealed on hover or focus, filled and
166
- always visible once the session is a favorite. */
181
+ /* The Favorites star: unfilled and revealed on hover or focus, filled when
182
+ selected. Desktop rows reveal it in the shared trailing slot; mobile keeps
183
+ the selected star visible as a persistent touch affordance. */
167
184
  .session-folder-star-btn {
168
185
  position: absolute; right: 52px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; z-index: 1;
169
186
  display: flex; align-items: center; justify-content: center; border: 0; border-radius: 999px; background: transparent;
@@ -172,13 +189,19 @@
172
189
  .session-folder-star-btn svg, .mobile-session-star svg { width: 12px; height: 12px; display: block; }
173
190
  .session-item:hover .session-folder-star-btn, .session-item:focus-within .session-folder-star-btn { opacity: 0.82; pointer-events: auto; }
174
191
  .session-folder-star-btn:hover { opacity: 1; color: var(--text); background: rgba(var(--overlay-rgb), 0.08); }
175
- .session-folder-star-btn.is-favorite, .mobile-session-star.is-favorite { color: var(--accent); opacity: 1; pointer-events: auto; }
192
+ .session-folder-star-btn.is-favorite, .mobile-session-star.is-favorite { color: var(--accent); }
193
+ .mobile-session-star.is-favorite { opacity: 1; pointer-events: auto; }
176
194
  .session-folder-star-btn.is-favorite svg, .mobile-session-star.is-favorite svg { fill: currentColor; }
177
195
  .session-folder-star-btn:focus-visible, .mobile-session-star:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
178
196
 
179
- /* Desktop row actions are a real flex column in the row, not three unrelated
180
- offsets over the age and linked-work metadata. Keeping the full cluster's
181
- footprint at rest also prevents the title from jumping when actions appear. */
197
+ /* Age and actions share one intrinsic trailing slot. CSS grid takes the wider
198
+ of the two without adding their widths, so hover/focus swaps content without
199
+ moving the title, linked work, presence, or row controls. */
200
+ .session-item .session-row-trailing {
201
+ flex: 0 0 auto; display: grid; align-items: center; justify-items: end; min-width: 0;
202
+ }
203
+ .session-item .session-row-trailing > .session-item-age,
204
+ .session-item .session-row-trailing > .session-row-actions { grid-area: 1 / 1; }
182
205
  .session-item .session-row-actions {
183
206
  flex: 0 0 auto; display: flex; align-items: center; justify-content: flex-end; gap: 4px;
184
207
  }
@@ -186,6 +209,9 @@
186
209
  .session-item .session-row-actions > .session-close-btn {
187
210
  position: static; flex: 0 0 18px; transform: none;
188
211
  }
212
+ .session-item .session-row-trailing .session-folder-star-btn.is-favorite { opacity: 0; pointer-events: none; }
213
+ .session-item:hover .session-row-trailing .session-folder-star-btn.is-favorite,
214
+ .session-item:focus-within .session-row-trailing .session-folder-star-btn.is-favorite { opacity: 0.82; pointer-events: auto; }
189
215
 
190
216
  /* Mobile: touch-sized targets, always-visible actions, no drag affordances. */
191
217
  .session-folder.is-mobile .session-folder-header { min-height: 40px; margin-top: 8px; }
@@ -237,7 +263,10 @@
237
263
  .session-folder-view-note { font-size: 12px; line-height: 1.45; color: var(--text-muted); }
238
264
 
239
265
  @media (prefers-reduced-motion: reduce) {
240
- .session-folder, .session-folder-chevron, .session-folder-toggle, .session-folder-tool { transition: none; }
266
+ .session-folder, .session-folder-chevron, .session-folder-toggle, .session-folder-tool, .session-count-summary::after { transition: none; }
267
+ .session-row-trailing .session-item-age,
268
+ .session-row-trailing .session-folder-star-btn,
269
+ .session-row-trailing .session-close-btn { transition: none; }
241
270
  .session-folder-body.folder-collapse-animating { will-change: auto; }
242
271
  }
243
272
 
@@ -2,7 +2,7 @@
2
2
  .session-work-column {
3
3
  display: flex;
4
4
  flex-direction: column;
5
- gap: 3px;
5
+ gap: 2px;
6
6
  flex: 1 1 auto;
7
7
  min-width: 0;
8
8
  overflow: visible;
@@ -28,10 +28,71 @@
28
28
  line-height: 18px;
29
29
  }
30
30
  .session-work-column .session-row-github-links {
31
- flex-wrap: wrap;
32
- gap: 3px 5px;
33
- overflow: visible;
31
+ flex-wrap: nowrap;
32
+ gap: 3px;
33
+ overflow: hidden;
34
+ line-height: 16px;
35
+ }
36
+ .session-row-github-links .session-github-link {
37
+ min-width: 0;
38
+ height: 18px;
39
+ box-sizing: border-box;
40
+ gap: 3px;
41
+ padding: 0 4px;
42
+ font-size: 10.5px;
43
+ }
44
+ .session-row-github-links .session-linear-link {
45
+ flex: 1 1 0;
46
+ min-width: 0;
47
+ max-width: 100%;
48
+ overflow: hidden;
49
+ }
50
+ .session-row-github-links:has(.github-kind-pr, .github-kind-issue) .session-linear-link { max-width: calc(100% - 76px); }
51
+ .linear-work-state-compact { display: none; }
52
+ .session-row-github-links .linear-work-id {
53
+ flex: 0 1 auto;
54
+ min-width: 0;
55
+ max-width: 52%;
56
+ overflow: hidden;
57
+ text-overflow: ellipsis;
58
+ white-space: nowrap;
59
+ }
60
+ .session-row-github-links .linear-work-state {
61
+ display: block;
62
+ flex: 1 4 0;
63
+ min-width: 0;
64
+ max-width: 52px;
65
+ overflow: hidden;
66
+ text-overflow: ellipsis;
67
+ white-space: nowrap;
34
68
  }
69
+ .session-row-github-links .linear-work-state-full { display: none; }
70
+ .session-row-github-links .linear-work-state-compact {
71
+ display: block;
72
+ overflow: hidden;
73
+ text-overflow: ellipsis;
74
+ white-space: nowrap;
75
+ }
76
+ .session-row-github-links .linear-work-title {
77
+ flex: 1 5 0;
78
+ min-width: 0;
79
+ max-width: 34px;
80
+ overflow: hidden;
81
+ text-overflow: ellipsis;
82
+ white-space: nowrap;
83
+ }
84
+ .session-row-github-links .github-kind-pr,
85
+ .session-row-github-links .github-kind-issue { flex: 0 0 auto; }
86
+ .session-row-github-links .github-work-brand { display: none; }
87
+ .session-row-github-links .github-work-brand svg,
88
+ .session-row-github-links .github-work-status svg { width: 11px; height: 11px; }
89
+ .session-row-github-links .github-work-more { height: 18px; line-height: 16px; padding: 0 3px; font-size: 10.5px; }
90
+ .session-row-github-links .linear-work-brand svg { width: 11px; height: 11px; }
91
+ .session-row-github-links .linear-work-brand { flex: none; }
92
+ .session-row-github-links .session-linear-link,
93
+ .session-row-github-links .session-github-link,
94
+ .session-row-github-links .github-work-more { touch-action: manipulation; }
95
+ .session-row-github-links { min-height: 18px; }
35
96
  .session-github-link {
36
97
  display: inline-flex;
37
98
  align-items: center;
@@ -60,10 +121,25 @@
60
121
  }
61
122
 
62
123
  .session-item:has(.session-work-column) {
124
+ position: relative;
125
+ container-type: inline-size;
63
126
  height: auto;
64
- min-height: 52px;
127
+ min-height: 46px;
65
128
  contain: layout;
66
129
  }
130
+ @container (max-width: 280px) {
131
+ .session-row-github-links .linear-work-title { display: none; }
132
+ }
133
+ .session-item:has(.session-work-column) > .session-vendor-mark,
134
+ .session-item:has(.session-work-column) > .session-work-column,
135
+ .session-item:has(.session-work-column) > .session-presence,
136
+ .session-item:has(.session-work-column) > .session-row-trailing { align-self: flex-start; margin-top: 5px; }
137
+ .session-item:has(.session-work-column) .session-row-github-links {
138
+ position: absolute;
139
+ left: 30px;
140
+ right: 44px;
141
+ bottom: 3px;
142
+ }
67
143
 
68
144
  .github-kind-pr.github-state-closed .github-work-status svg { color: #f85149; }
69
145
  @media (max-width: 600px) {
@@ -117,6 +193,11 @@
117
193
  .github-work-entry-meta { color: var(--text-muted, #888); font-size: 11px; overflow-wrap: anywhere; }
118
194
  @media (max-width: 600px) {
119
195
  #header-github-links { max-width: 120px; }
120
- .mobile-session-item:has(.session-work-column) { align-items: flex-start; }
121
- .mobile-session-item .github-work-more { min-height: 28px; }
196
+ .mobile-session-item:has(.session-work-column) { position: relative; container-type: inline-size; align-items: flex-start; min-height: 70px; padding-block: 8px; }
197
+ .mobile-session-item:has(.session-work-column) .session-work-column { position: static; }
198
+ .mobile-session-item:has(.session-work-column) .session-row-github-links { position: absolute; left: 44px; right: 54px; bottom: 8px; }
199
+ .mobile-session-item .session-row-github-links .session-github-link,
200
+ .mobile-session-item .session-row-github-links .github-work-more { min-height: 28px; height: 28px; }
201
+ .mobile-session-item .session-row-github-links .github-work-more { padding-inline: 2px; }
202
+ .mobile-session-item .session-row-github-links { min-height: 28px; }
122
203
  }
@@ -3,6 +3,7 @@ import { getWs } from './ws-ref.js';
3
3
  import { showConfirm } from './app-misc.js';
4
4
  import { describeGroupDelete } from './session-delete-scope.js';
5
5
  import { showToast } from './utils.js';
6
+ import { renderSidebarCount } from './sidebar-session-counts.js';
6
7
 
7
8
  function confirmClear(label, ids, sessions) {
8
9
  if (!ids.length) return;
@@ -22,7 +23,7 @@ function confirmClear(label, ids, sessions) {
22
23
  });
23
24
  }
24
25
 
25
- export function renderDateGroupHeader(dateLabel, folderLabel, ids, sessions, surface, searching) {
26
+ export function renderDateGroupHeader(dateLabel, folderLabel, ids, sessions, surface, searching, displayCounts) {
26
27
  var header = document.createElement("div");
27
28
  header.className = "session-group-header session-date-group-header" + (surface === "mobile" ? " is-mobile" : "");
28
29
  header.setAttribute("role", "heading");
@@ -33,10 +34,7 @@ export function renderDateGroupHeader(dateLabel, folderLabel, ids, sessions, sur
33
34
  label.textContent = dateLabel;
34
35
  header.appendChild(label);
35
36
 
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"));
37
+ var count = renderSidebarCount("session-date-group-count", displayCounts || { roots: ids.length, workers: 0 }, true);
40
38
  header.appendChild(count);
41
39
 
42
40
  if ((!store.get('permissions') || store.get('permissions').sessionDelete !== false) && ids.length) {
@@ -13,6 +13,7 @@ import {
13
13
  openListSearch, closeListSearch, clearListSearchText, setListSearchQuery, rememberListSearchCaret,
14
14
  getListSearchQuery, getListSearchMatchIds, listSearchNeedsRender
15
15
  } from './session-list-search.js';
16
+ import { renderSidebarCount } from './sidebar-session-counts.js';
16
17
 
17
18
  // Visible one wins: the desktop list and the mobile sheet can both be in the DOM.
18
19
  function visible(selector) {
@@ -44,7 +45,7 @@ export function folderToolbarNeedsRender(state, previous) {
44
45
  return folderDraftNeedsRender(state, previous) || listSearchNeedsRender(state, previous);
45
46
  }
46
47
 
47
- function renderSearchBar(bar, surface) {
48
+ function renderSearchBar(bar, surface, displayCounts) {
48
49
  var state = store.get('sessionSearch');
49
50
  var query = getListSearchQuery();
50
51
  var matches = getListSearchMatchIds();
@@ -92,9 +93,8 @@ function renderSearchBar(bar, surface) {
92
93
  }, 0);
93
94
  });
94
95
 
95
- var count = document.createElement("span");
96
- count.className = "session-search-count";
97
- if (query && matches !== null) count.textContent = String(matches.size);
96
+ var count = renderSidebarCount("session-search-count", displayCounts || { roots: matches ? matches.size : 0, workers: 0 }, true);
97
+ if (!query || matches === null) count.textContent = "";
98
98
  var clear = document.createElement("button");
99
99
  clear.type = "button";
100
100
  clear.className = "session-search-clear";
@@ -130,7 +130,7 @@ function renderSearchBar(bar, surface) {
130
130
  });
131
131
  }
132
132
 
133
- export function renderFolderToolbar(surface) {
133
+ export function renderFolderToolbar(surface, displayCounts) {
134
134
  var create = store.get('sessionFolderCreate');
135
135
  var search = store.get('sessionSearch');
136
136
  var wrap = document.createElement("div");
@@ -140,7 +140,7 @@ export function renderFolderToolbar(surface) {
140
140
  wrap.appendChild(bar);
141
141
 
142
142
  if (search && search.open) {
143
- renderSearchBar(bar, surface);
143
+ renderSearchBar(bar, surface, displayCounts);
144
144
  return wrap;
145
145
  }
146
146
 
@@ -21,6 +21,12 @@ function safeLinks(session) {
21
21
  function label(link) {
22
22
  return (link.kind === 'pr' ? 'PR #' : 'Issue #') + link.number;
23
23
  }
24
+ function compactLinearState(state) {
25
+ var value = String(state || '');
26
+ if (/^in progress$/i.test(value)) return 'Active';
27
+ if (/^cancell?ed$/i.test(value)) return 'Canceled';
28
+ return value.length > 9 ? value.slice(0, 8) + '\u2026' : value;
29
+ }
24
30
  function anchor(url, title, content, className) {
25
31
  return '<a class="' + className + '" href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer" title="' + escapeHtml(title) + '" aria-label="' + escapeHtml(title) + '">' + content + '</a>';
26
32
  }
@@ -43,7 +49,7 @@ export function githubWorkMarkup(session, detailed, expanded, sessionRow) {
43
49
  return '<span class="session-github-links' + (detailed ? ' github-work-details' : '') + (sessionRow ? ' session-row-github-links' : '') + '">' + displayed.map(function (link) {
44
50
  if (link.provider === 'linear') {
45
51
  var description = link.identifier + ' · ' + link.title + ' · ' + link.stateName + (link.stale ? ' · Last known status' : '');
46
- return anchor(link.url, description, LINEAR_MARK + '<span class="linear-work-id">' + escapeHtml(link.identifier) + '</span><span class="linear-work-title">' + escapeHtml(link.title) + '</span><span class="linear-work-state">' + escapeHtml(link.stateName) + '</span>', 'session-github-link session-linear-link');
52
+ return anchor(link.url, description, LINEAR_MARK + '<span class="linear-work-id">' + escapeHtml(link.identifier) + '</span><span class="linear-work-title">' + escapeHtml(link.title) + '</span><span class="linear-work-state"><span class="linear-work-state-full">' + escapeHtml(link.stateName) + '</span><span class="linear-work-state-compact" aria-hidden="true">' + escapeHtml(compactLinearState(link.stateName)) + '</span></span>', 'session-github-link session-linear-link');
47
53
  }
48
54
  var state = ['open', 'closed', 'merged', 'draft'].indexOf(link.state) >= 0 ? link.state : 'unknown';
49
55
  var icon = link.kind === 'pr' ? (state === 'merged' ? 'git-merge' : state === 'closed' ? 'git-pull-request-closed' : state === 'draft' ? 'git-pull-request-draft' : 'git-pull-request') : (state === 'closed' ? 'circle-check' : 'circle-dot');
@@ -38,7 +38,7 @@ export function renderSessionPresence(row, sessionId) {
38
38
  container.appendChild(more);
39
39
  }
40
40
 
41
- var anchor = row.querySelector(".session-item-age, .mobile-worker-generation, .mobile-session-unread, .session-row-actions, .mobile-session-star");
41
+ var anchor = row.querySelector(".session-row-trailing, .session-item-age, .mobile-worker-generation, .mobile-session-unread, .session-row-actions, .mobile-session-star");
42
42
  row.insertBefore(container, anchor || null);
43
43
  return container;
44
44
  }
@@ -34,6 +34,7 @@ import {
34
34
  import { store } from './store.js';
35
35
  import { getWs } from './ws-ref.js';
36
36
  import { dismissOverlayPanels, closeSidebar } from './sidebar.js';
37
+ import { openWorkerWithDriver } from './split-view.js';
37
38
  import { switchProject, getCachedProjects } from './app-projects.js';
38
39
  import { showHomeHub } from './app-home-hub.js';
39
40
  import { openHomeChat } from './home-mate-chat.js';
@@ -45,6 +46,7 @@ import {
45
46
  renderMobileDriverHierarchy,
46
47
  renderMobileOrphanHierarchy
47
48
  } from './sidebar-session-hierarchy.js';
49
+ import { summarizeSidebarItems } from './sidebar-session-counts.js';
48
50
  import { cancelSessionInlineRename, closeSessionContextMenu } from './session-context-menu.js';
49
51
 
50
52
  // --- Mobile state ---
@@ -443,15 +445,17 @@ function createMobileSessionItem(s, options) {
443
445
  openSessionContextMenu(el, s);
444
446
  });
445
447
 
446
- (function (id) {
448
+ (function (session, worker) {
447
449
  el.addEventListener("click", function () {
448
450
  if (getWs() && store.get('connected')) {
449
- getWs().send(JSON.stringify({ type: "switch_session", id: id }));
451
+ if (!worker || !openWorkerWithDriver(session)) {
452
+ getWs().send(JSON.stringify({ type: "switch_session", id: session.id }));
453
+ }
450
454
  }
451
455
  if (dismissOverlayPanels) dismissOverlayPanels();
452
456
  closeMobileSheet();
453
457
  });
454
- })(s.id);
458
+ })(s, !!itemOptions.worker);
455
459
 
456
460
  return el;
457
461
  }
@@ -745,15 +749,16 @@ export function renderMobileSessionsInto(container) {
745
749
  var folderCtx = {
746
750
  surface: "mobile",
747
751
  searching: matchIds !== null,
752
+ matchIds: matchIds,
748
753
  sessions: getCachedSessions(),
749
754
  renderItem: function (it) {
750
755
  if (it.type === "loop") return createMobileLoopGroup(it.loopId, it.children, it.groupKey);
751
- if (it.type === "driver-hierarchy") return renderMobileDriverHierarchy(it.root, createMobileSessionItem, refreshMobileChatSheet);
752
- if (it.type === "orphan-workers") return renderMobileOrphanHierarchy(it.workers, createMobileSessionItem, refreshMobileChatSheet);
756
+ if (it.type === "driver-hierarchy") return renderMobileDriverHierarchy(it.root, createMobileSessionItem, refreshMobileChatSheet, matchIds);
757
+ if (it.type === "orphan-workers") return renderMobileOrphanHierarchy(it.workers, createMobileSessionItem, refreshMobileChatSheet, matchIds);
753
758
  return createMobileSessionItem(it.data);
754
759
  },
755
760
  };
756
- container.appendChild(renderFolderToolbar("mobile"));
761
+ container.appendChild(renderFolderToolbar("mobile", summarizeSidebarItems(filterItemsBySearch(items, matchIds), matchIds)));
757
762
  container.appendChild(renderFavoritesSection(layout, folderCtx));
758
763
  renderOrdinarySections(layout, folderCtx, container);
759
764
  }
@@ -0,0 +1,65 @@
1
+ // Display-only sidebar counts. Destructive Clear actions keep their separate
2
+ // authoritative session-id scope and cascade semantics.
3
+
4
+ function sessionId(session) {
5
+ return session && typeof session.id === "number" ? session.id : null;
6
+ }
7
+
8
+ function addSession(session, roots, workers) {
9
+ var id = sessionId(session);
10
+ if (id === null) return;
11
+ if (session.sessionRole === "worker") workers.add(id);
12
+ else roots.add(id);
13
+ }
14
+
15
+ function visibleWorker(worker, driver, matchIds) {
16
+ return matchIds === null || matchIds === undefined || matchIds.has(worker.id) || matchIds.has(driver.id);
17
+ }
18
+
19
+ function addItem(item, roots, workers, matchIds) {
20
+ if (!item) return;
21
+ if (item.type === "session") {
22
+ addSession(item.data, roots, workers);
23
+ return;
24
+ }
25
+ if (item.type === "driver-hierarchy") {
26
+ addSession(item.root && item.root.driver, roots, workers);
27
+ var children = item.root && item.root.workers || [];
28
+ for (var i = 0; i < children.length; i++) if (visibleWorker(children[i], item.root.driver, matchIds)) addSession(children[i], roots, workers);
29
+ return;
30
+ }
31
+ var sessions = item.type === "orphan-workers" ? item.workers : item.type === "split-group" ? item.members : item.type === "loop" ? item.children : [];
32
+ for (var j = 0; j < (sessions || []).length; j++) {
33
+ if (item.type === "orphan-workers" && matchIds !== null && matchIds !== undefined && !matchIds.has(sessions[j].id)) continue;
34
+ addSession(sessions[j], roots, workers);
35
+ }
36
+ }
37
+
38
+ export function summarizeSidebarItems(items, matchIds) {
39
+ var roots = new Set();
40
+ var workers = new Set();
41
+ for (var i = 0; i < (items || []).length; i++) addItem(items[i], roots, workers, matchIds);
42
+ return { roots: roots.size, workers: workers.size };
43
+ }
44
+
45
+ export function summarizeSidebarUnits(units, matchIds) {
46
+ return summarizeSidebarItems((units || []).map(function (unit) { return unit.item; }), matchIds);
47
+ }
48
+
49
+ export function sidebarCountLabel(counts, short) {
50
+ var roots = counts && counts.roots || 0;
51
+ var workers = counts && counts.workers || 0;
52
+ if (short) return roots + (roots === 1 ? " Driver" : " Drivers") + " · " + workers + (workers === 1 ? " Split Worker" : " Split Workers");
53
+ return roots + (roots === 1 ? " Driver session" : " Driver sessions") + " and " + workers + (workers === 1 ? " Split Worker session" : " Split Worker sessions");
54
+ }
55
+
56
+ export function renderSidebarCount(className, counts, focusable) {
57
+ var count = document.createElement("span");
58
+ count.className = className + " session-count-summary";
59
+ count.textContent = String(counts.roots);
60
+ count.dataset.countDetail = sidebarCountLabel(counts, true);
61
+ count.setAttribute("aria-label", sidebarCountLabel(counts, false));
62
+ count.title = sidebarCountLabel(counts, false);
63
+ if (focusable) count.tabIndex = 0;
64
+ return count;
65
+ }
@@ -22,6 +22,7 @@ import { renderNewSessionButton, renderCreateRow } from './session-create-form.j
22
22
  import { restoreFolderCollapse, toggleFolderCollapse } from './session-folder-collapse.js';
23
23
  import { groupUnitsByDate, groupRootSessionIds } from './session-date-groups.js';
24
24
  import { renderDateGroupHeader } from './session-date-group-header.js';
25
+ import { summarizeSidebarUnits, sidebarCountLabel, renderSidebarCount } from './sidebar-session-counts.js';
25
26
 
26
27
  // Mutable UI state (open menu, drag in flight, the layout each surface last
27
28
  // rendered) lives in the store; this module only owns the functions.
@@ -269,15 +270,6 @@ function wireUnitDrop(wrapper, containerId, key) {
269
270
 
270
271
  // --- Rendering ---
271
272
 
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
273
  function renderUnit(unit, containerId, ctx) {
282
274
  var wrapper = document.createElement("div");
283
275
  wrapper.className = "session-folder-unit";
@@ -317,14 +309,17 @@ function renderSection(section, ctx, kind) {
317
309
  toggle.className = "session-folder-toggle";
318
310
  toggle.setAttribute("aria-expanded", String(!section.collapsed));
319
311
  toggle.setAttribute("aria-controls", bodyId);
312
+ var counts = summarizeSidebarUnits(section.units, ctx.matchIds);
313
+ toggle.setAttribute("aria-label", (section.collapsed ? "Expand " : "Collapse ") + section.label + ". " + sidebarCountLabel(counts, false));
320
314
  var icon = isFavorites ? "star" : (kind === "unfiled" ? "inbox" : "folder");
321
315
  toggle.innerHTML = '<span class="session-folder-chevron">' + iconHtml("chevron-right") + "</span>" +
322
316
  '<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>";
317
+ '<span class="session-folder-label">' + escapeHtml(section.label) + "</span>";
318
+ toggle.appendChild(renderSidebarCount("session-folder-count", counts, false));
325
319
  toggle.addEventListener("click", function () {
326
320
  if (consumeFolderClickSuppress()) return;
327
321
  toggleFolderCollapse(root, body, toggle, surface, section.id, function (collapsed) {
322
+ toggle.setAttribute("aria-label", (collapsed ? "Expand " : "Collapse ") + section.label + ". " + sidebarCountLabel(counts, false));
328
323
  sendFolderOp({ op: "set_collapsed", containerKey: section.id, collapsed: collapsed });
329
324
  });
330
325
  });
@@ -362,7 +357,7 @@ function renderSection(section, ctx, kind) {
362
357
  if (dateGroups) {
363
358
  for (var g = 0; g < dateGroups.length; g++) {
364
359
  var ids = groupRootSessionIds(dateGroups[g].units);
365
- body.appendChild(renderDateGroupHeader(dateGroups[g].label, section.label, ids, ctx.sessions || [], surface, !!ctx.searching));
360
+ body.appendChild(renderDateGroupHeader(dateGroups[g].label, section.label, ids, ctx.sessions || [], surface, !!ctx.searching, summarizeSidebarUnits(dateGroups[g].units, ctx.matchIds)));
366
361
  for (var gu = 0; gu < dateGroups[g].units.length; gu++) body.appendChild(renderUnit(dateGroups[g].units[gu], section.id, ctx));
367
362
  }
368
363
  } else {
@@ -32,7 +32,15 @@ export function currentWorkerIds() {
32
32
  var result = new Set();
33
33
  var groups = store.get('splitGroups') || [];
34
34
  for (var i = 0; i < groups.length; i++) {
35
- if (groups[i].pair && Number.isInteger(groups[i].pair.workerId)) result.add(groups[i].pair.workerId);
35
+ var pair = groups[i].pair;
36
+ if (!pair) continue;
37
+ if (Array.isArray(pair.workerIds)) {
38
+ for (var j = 0; j < pair.workerIds.length; j++) {
39
+ if (Number.isInteger(pair.workerIds[j])) result.add(pair.workerIds[j]);
40
+ }
41
+ } else if (Number.isInteger(pair.workerId)) {
42
+ result.add(pair.workerId);
43
+ }
36
44
  }
37
45
  return result;
38
46
  }
@@ -55,22 +63,23 @@ export function hierarchyItemMatches(item, matchIds) {
55
63
  return true;
56
64
  }
57
65
 
58
- function expanded(surface, key, workers, matchIds) {
66
+ function expansionKey(key) {
67
+ return String(store.get('currentSlug') || '') + ":" + key;
68
+ }
69
+
70
+ function expanded(surface, key) {
59
71
  var state = expansionBySurface[surface];
60
- if (state.has(key)) return state.get(key);
61
- return defaultHierarchyExpanded(workers, matchIds, currentWorkerIds());
72
+ var scopedKey = expansionKey(key);
73
+ if (state.has(scopedKey)) return state.get(scopedKey);
74
+ return defaultHierarchyExpanded();
62
75
  }
63
76
 
64
- export function defaultHierarchyExpanded(workers, matchIds, current) {
65
- var currentIds = current || new Set();
66
- for (var i = 0; i < workers.length; i++) {
67
- if (workers[i].active || currentIds.has(workers[i].id) || (matchIds !== null && matchIds.has(workers[i].id))) return true;
68
- }
77
+ export function defaultHierarchyExpanded() {
69
78
  return false;
70
79
  }
71
80
 
72
81
  function toggle(surface, key, wasExpanded, rerender) {
73
- expansionBySurface[surface].set(key, !wasExpanded);
82
+ expansionBySurface[surface].set(expansionKey(key), !wasExpanded);
74
83
  rerender();
75
84
  }
76
85
 
@@ -82,7 +91,7 @@ export function renderDesktopDriverHierarchy(root, renderSession, rerender, matc
82
91
  var wrapper = document.createElement("div");
83
92
  wrapper.className = "session-driver-hierarchy";
84
93
  var key = "driver:" + root.driver.id;
85
- var isExpanded = expanded("desktop", key, root.workers, matchIds);
94
+ var isExpanded = expanded("desktop", key);
86
95
  var childrenId = "session-workers-" + root.driver.id;
87
96
  var header = document.createElement("div");
88
97
  header.className = "session-driver-header";
@@ -105,7 +114,7 @@ export function renderDesktopDriverHierarchy(root, renderSession, rerender, matc
105
114
  event.preventDefault();
106
115
  event.stopPropagation();
107
116
  var nextExpanded = !isExpanded;
108
- expansionBySurface.desktop.set(key, nextExpanded);
117
+ expansionBySurface.desktop.set(expansionKey(key), nextExpanded);
109
118
  control.setAttribute("aria-expanded", String(nextExpanded));
110
119
  control.setAttribute("aria-label", (nextExpanded ? "Collapse" : "Expand") + " Workers for " + (root.driver.title || "New Session"));
111
120
  children.hidden = !nextExpanded;
@@ -127,7 +136,7 @@ export function renderDesktopDriverHierarchy(root, renderSession, rerender, matc
127
136
  export function renderDesktopOrphanHierarchy(workers, renderSession, rerender, matchIds) {
128
137
  var wrapper = document.createElement("div");
129
138
  wrapper.className = "session-driver-hierarchy session-orphan-hierarchy";
130
- var isExpanded = expanded("desktop", "orphan", workers, matchIds);
139
+ var isExpanded = expanded("desktop", "orphan");
131
140
  var control = document.createElement("button");
132
141
  control.type = "button";
133
142
  control.className = "session-orphan-toggle";
@@ -152,11 +161,11 @@ export function renderDesktopOrphanHierarchy(workers, renderSession, rerender, m
152
161
  return wrapper;
153
162
  }
154
163
 
155
- export function renderMobileDriverHierarchy(root, renderSession, rerender) {
164
+ export function renderMobileDriverHierarchy(root, renderSession, rerender, matchIds) {
156
165
  var wrapper = document.createElement("div");
157
166
  wrapper.className = "mobile-driver-hierarchy";
158
167
  var key = "driver:" + root.driver.id;
159
- var isExpanded = expanded("mobile", key, root.workers, null);
168
+ var isExpanded = expanded("mobile", key);
160
169
  var childrenId = "mobile-session-workers-" + root.driver.id;
161
170
  var header = document.createElement("div");
162
171
  header.className = "mobile-driver-header";
@@ -180,16 +189,17 @@ export function renderMobileDriverHierarchy(root, renderSession, rerender) {
180
189
  children.hidden = !isExpanded;
181
190
  var current = currentWorkerIds();
182
191
  for (var i = 0; i < root.workers.length; i++) {
192
+ if (!visibleWorker(root.workers[i], root.driver, matchIds === undefined ? null : matchIds)) continue;
183
193
  children.appendChild(renderSession(root.workers[i], { worker: true, current: current.has(root.workers[i].id) }));
184
194
  }
185
195
  wrapper.appendChild(children);
186
196
  return wrapper;
187
197
  }
188
198
 
189
- export function renderMobileOrphanHierarchy(workers, renderSession, rerender) {
199
+ export function renderMobileOrphanHierarchy(workers, renderSession, rerender, matchIds) {
190
200
  var wrapper = document.createElement("div");
191
201
  wrapper.className = "mobile-driver-hierarchy mobile-orphan-hierarchy";
192
- var isExpanded = expanded("mobile", "orphan", workers, null);
202
+ var isExpanded = expanded("mobile", "orphan");
193
203
  var control = document.createElement("button");
194
204
  control.type = "button";
195
205
  control.className = "mobile-orphan-toggle";
@@ -207,6 +217,7 @@ export function renderMobileOrphanHierarchy(workers, renderSession, rerender) {
207
217
  children.hidden = !isExpanded;
208
218
  var current = currentWorkerIds();
209
219
  for (var i = 0; i < workers.length; i++) {
220
+ if (matchIds !== null && matchIds !== undefined && !matchIds.has(workers[i].id)) continue;
210
221
  children.appendChild(renderSession(workers[i], { worker: true, current: current.has(workers[i].id) }));
211
222
  }
212
223
  wrapper.appendChild(children);
@@ -13,7 +13,7 @@ import { showConfirm } from './app-misc.js';
13
13
  import { getUpcomingSchedules } from './scheduler.js';
14
14
  import { refreshMobileChatSheet } from './sidebar-mobile.js';
15
15
  import { VENDOR_AVATARS, VENDOR_NAMES } from './app-rendering.js';
16
- import { openGroup, separateGroup } from './split-view.js';
16
+ import { openGroup, openWorkerWithDriver, separateGroup } from './split-view.js';
17
17
  import { groupedSessionIds } from './split-group-helpers.js';
18
18
  import { resolvePreferredVendor } from './default-vendor.js';
19
19
  import {
@@ -31,6 +31,7 @@ import {
31
31
  renderDesktopDriverHierarchy,
32
32
  renderDesktopOrphanHierarchy
33
33
  } from './sidebar-session-hierarchy.js';
34
+ import { summarizeSidebarItems } from './sidebar-session-counts.js';
34
35
  import {
35
36
  cancelSessionInlineRename,
36
37
  closeSessionContextMenu,
@@ -841,12 +842,14 @@ function renderSessionItem(s, options) {
841
842
  el.appendChild(workerLabel);
842
843
  }
843
844
 
845
+ var trailing = document.createElement("span");
846
+ trailing.className = "session-row-trailing";
844
847
  var ageText = relativeTime(s.lastActivity || 0);
845
848
  if (ageText) {
846
849
  var age = document.createElement("span");
847
850
  age.className = "session-item-age";
848
851
  age.textContent = ageText;
849
- el.appendChild(age);
852
+ trailing.appendChild(age);
850
853
  }
851
854
 
852
855
  // Right-click / long-press: context menu
@@ -874,13 +877,16 @@ function renderSessionItem(s, options) {
874
877
  if (isFavoriteSession(s)) el.classList.add("is-favorite");
875
878
  }
876
879
  appendSessionCloseButton(actions, s);
877
- if (actions.childNodes.length) el.appendChild(actions);
880
+ if (actions.childNodes.length) trailing.appendChild(actions);
881
+ if (trailing.childNodes.length) el.appendChild(trailing);
878
882
 
879
- el.addEventListener("click", (function (id) {
883
+ el.addEventListener("click", (function (session, worker) {
880
884
  return function () {
881
885
  if (getWs() && store.get('connected')) {
882
886
  var pendingQuery = getListSearchQuery() || "";
883
- getWs().send(JSON.stringify({ type: "switch_session", id: id }));
887
+ if (!worker || !openWorkerWithDriver(session)) {
888
+ getWs().send(JSON.stringify({ type: "switch_session", id: session.id }));
889
+ }
884
890
  dismissOverlayPanels();
885
891
  closeSidebar();
886
892
  if (pendingQuery) {
@@ -888,7 +894,7 @@ function renderSessionItem(s, options) {
888
894
  }
889
895
  }
890
896
  };
891
- })(s.id));
897
+ })(s, !!itemOptions.worker));
892
898
 
893
899
  // Presence avatars (multi-user)
894
900
  renderSessionPresence(el, String(s.id));
@@ -1071,6 +1077,7 @@ export function renderSessionList(sessions) {
1071
1077
  var folderCtx = {
1072
1078
  surface: "desktop",
1073
1079
  searching: matchIds !== null,
1080
+ matchIds: matchIds,
1074
1081
  sessions: cachedSessions,
1075
1082
  renderItem: function (it) {
1076
1083
  if (it.type === "loop") return renderLoopGroup(it.loopId, it.children, it.groupKey) || document.createElement("div");
@@ -1085,7 +1092,7 @@ export function renderSessionList(sessions) {
1085
1092
  // custom folders, then Unfiled. Each header has its own New session button.
1086
1093
  var topActions = document.createElement("div");
1087
1094
  topActions.className = "session-list-sticky-top";
1088
- topActions.appendChild(renderFolderToolbar("desktop"));
1095
+ topActions.appendChild(renderFolderToolbar("desktop", summarizeSidebarItems(visibleItems, matchIds)));
1089
1096
  getSessionListEl().appendChild(topActions);
1090
1097
 
1091
1098
  var regularContainer = document.createElement("div");
@@ -72,6 +72,24 @@ export function isConfiguredWorker(groups, sessionId) {
72
72
  return false;
73
73
  }
74
74
 
75
+ export function resolveWorkerSplitTarget(worker, sessions, groups) {
76
+ if (!worker || worker.sessionRole !== "worker" || !worker.parentAvailable || !Number.isInteger(worker.parentSessionId)) return null;
77
+ var list = sessions || [];
78
+ var driver = null;
79
+ for (var i = 0; i < list.length; i++) {
80
+ if (list[i].id === worker.parentSessionId && list[i].sessionRole !== "worker") driver = list[i];
81
+ }
82
+ if (!driver) return null;
83
+ var groupList = groups || [];
84
+ for (var j = 0; j < groupList.length; j++) {
85
+ var roles = splitGroupRoles(groupList[j]);
86
+ if (roles && roles.driverId === driver.id && roles.workerIds.indexOf(worker.id) !== -1) {
87
+ return { driver: driver, worker: worker, group: groupList[j] };
88
+ }
89
+ }
90
+ return { driver: driver, worker: worker, group: null };
91
+ }
92
+
75
93
  export function splitWorkerCloseRequest(group, sessions, projectSlug, workerId, requestId) {
76
94
  var roles = splitGroupRoles(group);
77
95
  if (!roles || roles.version !== 2 || roles.workerIds.indexOf(workerId) === -1) return null;
@@ -6,7 +6,7 @@ import { getWs } from './ws-ref.js';
6
6
  import { getCachedSessions } from './sidebar-sessions.js';
7
7
  import { refreshIcons } from './icons.js';
8
8
  import { detachTuiView } from './session-tui-view.js';
9
- import { groupedSessionIds, findSplitGroup, isConfiguredWorker, splitGroupRoles, splitGroupMemberIds, splitGroupActiveAnchor, splitWorkerCloseRequest } from './split-group-helpers.js';
9
+ import { groupedSessionIds, findSplitGroup, isConfiguredWorker, resolveWorkerSplitTarget, splitGroupRoles, splitGroupMemberIds, splitGroupActiveAnchor, splitWorkerCloseRequest } from './split-group-helpers.js';
10
10
  import { syncPairChrome } from './split-pair-ui.js';
11
11
  import { presentMarkdownEdit } from './filebrowser.js';
12
12
  import { autoStartLoginIfNeeded } from './vendor-login.js';
@@ -250,6 +250,28 @@ export function openGroup(group) {
250
250
  return true;
251
251
  }
252
252
 
253
+ export function openWorkerWithDriver(worker) {
254
+ var target = resolveWorkerSplitTarget(worker, getCachedSessions(), store.get('splitGroups'));
255
+ if (!target) return false;
256
+ if (target.group) return openGroup(target.group);
257
+ detachTuiView();
258
+ store.set({
259
+ splitPanes: {
260
+ groupId: null,
261
+ panes: [paneForSession(target.driver.id), paneForSession(target.worker.id)],
262
+ },
263
+ });
264
+ var activeId = store.get('activeSessionId');
265
+ if (activeId !== target.driver.id) {
266
+ var ws = getWs();
267
+ if (isCurrentProjectSessionReady(store.snap(), ws)) {
268
+ ws.send(JSON.stringify({ type: "switch_session", id: target.driver.id }));
269
+ }
270
+ }
271
+ dismissSplitOverlays();
272
+ return true;
273
+ }
274
+
253
275
  // Reload survival: when the restored active session turns out to be a split
254
276
  // group member, reopen that group. Safe to call often -- no-ops unless a
255
277
  // group member is active natively with no split open.
@@ -366,6 +388,8 @@ function handleSidebarSessionClick(event) {
366
388
  if (!sessionId) return;
367
389
  event.preventDefault();
368
390
  event.stopImmediatePropagation();
391
+ var session = sessionById(sessionId);
392
+ if (session && session.sessionRole === "worker" && openWorkerWithDriver(session)) return;
369
393
  switchNativeSession(sessionId);
370
394
  }
371
395
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "5.0.1",
3
+ "version": "5.0.2-beta.1",
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"