@design.estate/dees-catalog 3.101.0 → 3.102.0

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.
@@ -10,7 +10,13 @@ import {
10
10
  cssManager,
11
11
  } from '@design.estate/dees-element';
12
12
  import { themeDefaultStyles } from '../../00theme.js';
13
- import type { IHarnessSessionContextDetail, IHarnessSessionMeta } from '../interfaces.js';
13
+ import type {
14
+ IHarnessGroupContextDetail,
15
+ IHarnessSessionContextDetail,
16
+ IHarnessSessionGroup,
17
+ IHarnessSessionMeta,
18
+ IHarnessSessionMoveDetail,
19
+ } from '../interfaces.js';
14
20
  import { harnessFormatRelativeTime } from '../harness.helpers.js';
15
21
  import '../../00group-utility/dees-icon/dees-icon.js';
16
22
 
@@ -20,11 +26,24 @@ declare global {
20
26
  }
21
27
  }
22
28
 
29
+ interface IDropSlot {
30
+ groupId: string | null;
31
+ index: number;
32
+ }
33
+
23
34
  /**
24
35
  * Searchable conversation list, embeddable in a sidebar or a DeesModal.
25
36
  * Emits `harness-session-select` on click, `harness-session-open` on
26
37
  * double-click or Enter, and `harness-session-context` with viewport
27
38
  * pointer coordinates on right-click (the browser menu is suppressed).
39
+ *
40
+ * With `enableGrouping`, sessions render under user-defined `groups` and can
41
+ * be dragged between groups and reordered. The component never mutates its
42
+ * inputs: drops emit `harness-session-move`, the "new group" button emits
43
+ * `harness-group-create-request`, and right-clicking a group header emits
44
+ * `harness-group-context` — the host persists changes and passes fresh
45
+ * `groups` back in. Sessions in no group render in the trailing ungrouped
46
+ * section, recency-sorted. While a search query is active the list is flat.
28
47
  */
29
48
  @customElement('dees-harness-session-list')
30
49
  export class DeesHarnessSessionList extends DeesElement {
@@ -43,9 +62,24 @@ export class DeesHarnessSessionList extends DeesElement {
43
62
  @property()
44
63
  accessor emptyText: string = 'No conversations yet.';
45
64
 
65
+ @property({ attribute: false })
66
+ accessor groups: IHarnessSessionGroup[] = [];
67
+
68
+ @property({ type: Boolean })
69
+ accessor enableGrouping: boolean = false;
70
+
46
71
  @state()
47
72
  accessor search: string = '';
48
73
 
74
+ @state()
75
+ accessor draggingSessionId: string = '';
76
+
77
+ @state()
78
+ accessor dropSlot: IDropSlot | undefined = undefined;
79
+
80
+ @state()
81
+ accessor collapsedGroupIds: ReadonlySet<string> = new Set<string>();
82
+
49
83
  public static styles = [
50
84
  themeDefaultStyles,
51
85
  cssManager.defaultStyles,
@@ -118,6 +152,10 @@ export class DeesHarnessSessionList extends DeesElement {
118
152
  box-shadow: inset 2px 0 0 var(--dees-color-accent-primary);
119
153
  }
120
154
 
155
+ .item.dragging {
156
+ opacity: 0.4;
157
+ }
158
+
121
159
  .itemTitle {
122
160
  display: flex;
123
161
  align-items: baseline;
@@ -155,9 +193,109 @@ export class DeesHarnessSessionList extends DeesElement {
155
193
  font-size: var(--dees-font-control-size-sm, 12px);
156
194
  text-align: center;
157
195
  }
196
+
197
+ .groupHeader {
198
+ display: flex;
199
+ align-items: center;
200
+ gap: var(--dees-spacing-xs);
201
+ width: 100%;
202
+ margin-top: var(--dees-spacing-xs);
203
+ padding: 2px var(--dees-spacing-sm);
204
+ border: 0;
205
+ border-radius: var(--dees-radius-md);
206
+ background: transparent;
207
+ color: var(--dees-color-text-secondary);
208
+ cursor: pointer;
209
+ font-family: inherit;
210
+ font-size: var(--dees-font-caption1-size, 11px);
211
+ font-weight: 650;
212
+ text-transform: uppercase;
213
+ letter-spacing: 0.05em;
214
+ text-align: left;
215
+ }
216
+
217
+ .groupHeader:hover {
218
+ background: var(--dees-color-hover);
219
+ }
220
+
221
+ .groupHeader .chevron {
222
+ transition: transform var(--dees-transition-fast) var(--dees-ease-standard);
223
+ }
224
+
225
+ .groupHeader.collapsed .chevron {
226
+ transform: rotate(-90deg);
227
+ }
228
+
229
+ .groupHeader .groupCount {
230
+ margin-left: auto;
231
+ font-weight: 500;
232
+ text-transform: none;
233
+ letter-spacing: normal;
234
+ color: var(--dees-color-text-muted);
235
+ }
236
+
237
+ .groupHeader.dropTarget {
238
+ outline: 1px dashed var(--dees-color-accent-primary);
239
+ outline-offset: -1px;
240
+ }
241
+
242
+ .dropLine {
243
+ height: 2px;
244
+ margin: 0 var(--dees-spacing-sm);
245
+ border-radius: 1px;
246
+ background: var(--dees-color-accent-primary);
247
+ }
248
+
249
+ .newGroupRow {
250
+ flex: 0 0 auto;
251
+ padding: var(--dees-spacing-xs) var(--dees-spacing-sm) var(--dees-spacing-sm);
252
+ }
253
+
254
+ .newGroupButton {
255
+ display: flex;
256
+ align-items: center;
257
+ gap: var(--dees-spacing-xs);
258
+ width: 100%;
259
+ padding: var(--dees-spacing-xs) var(--dees-spacing-sm);
260
+ border: 1px dashed var(--dees-color-border-default);
261
+ border-radius: var(--dees-radius-md);
262
+ background: transparent;
263
+ color: var(--dees-color-text-secondary);
264
+ cursor: pointer;
265
+ font-family: inherit;
266
+ font-size: var(--dees-font-control-size-sm, 12px);
267
+ }
268
+
269
+ .newGroupButton:hover {
270
+ border-color: var(--dees-color-accent-primary);
271
+ color: var(--dees-color-text-primary);
272
+ }
158
273
  `,
159
274
  ];
160
275
 
276
+ private get sessionsById(): Map<string, IHarnessSessionMeta> {
277
+ return new Map(this.sessions.map((session) => [session.id, session]));
278
+ }
279
+
280
+ private get groupedSessionIds(): Set<string> {
281
+ const ids = new Set<string>();
282
+ for (const group of this.groups) {
283
+ for (const id of group.sessionIds) ids.add(id);
284
+ }
285
+ return ids;
286
+ }
287
+
288
+ /** Ungrouped sessions, most recent first. */
289
+ private get ungroupedSessions(): IHarnessSessionMeta[] {
290
+ const grouped = this.groupedSessionIds;
291
+ return [...this.sessions]
292
+ .filter((session) => !grouped.has(session.id))
293
+ .sort(
294
+ (left, right) =>
295
+ (right.updatedAt ?? right.createdAt ?? 0) - (left.updatedAt ?? left.createdAt ?? 0),
296
+ );
297
+ }
298
+
161
299
  private get filteredSessions(): IHarnessSessionMeta[] {
162
300
  const query = this.search.trim().toLowerCase();
163
301
  const sorted = [...this.sessions].sort(
@@ -174,7 +312,7 @@ export class DeesHarnessSessionList extends DeesElement {
174
312
  }
175
313
 
176
314
  public render(): TemplateResult {
177
- const sessions = this.filteredSessions;
315
+ const groupingActive = this.enableGrouping && !this.search.trim();
178
316
  return html`
179
317
  <div class="searchRow">
180
318
  <input
@@ -188,18 +326,138 @@ export class DeesHarnessSessionList extends DeesElement {
188
326
  <div class="list">
189
327
  ${this.loading
190
328
  ? html`<div class="emptyNote">Loading conversations…</div>`
191
- : sessions.length
192
- ? sessions.map((session) => this.renderItem(session))
193
- : html`<div class="emptyNote">${this.sessions.length ? 'No conversations match this search.' : this.emptyText}</div>`}
329
+ : groupingActive
330
+ ? this.renderGroupedList()
331
+ : this.renderFlatList()}
194
332
  </div>
333
+ ${groupingActive
334
+ ? html`
335
+ <div class="newGroupRow">
336
+ <button
337
+ class="newGroupButton"
338
+ type="button"
339
+ @click=${() => {
340
+ this.dispatchEvent(
341
+ new CustomEvent('harness-group-create-request', {
342
+ bubbles: true,
343
+ composed: true,
344
+ }),
345
+ );
346
+ }}
347
+ >
348
+ <dees-icon icon="lucide:FolderPlus" iconSize="13"></dees-icon>
349
+ New group
350
+ </button>
351
+ </div>
352
+ `
353
+ : ''}
354
+ `;
355
+ }
356
+
357
+ private renderFlatList(): TemplateResult {
358
+ const sessions = this.filteredSessions;
359
+ if (!sessions.length) {
360
+ return html`<div class="emptyNote">
361
+ ${this.sessions.length ? 'No conversations match this search.' : this.emptyText}
362
+ </div>`;
363
+ }
364
+ return html`${sessions.map((session) => this.renderItem(session, null, -1))}`;
365
+ }
366
+
367
+ private renderGroupedList(): TemplateResult {
368
+ const byId = this.sessionsById;
369
+ const ungrouped = this.ungroupedSessions;
370
+ if (!this.sessions.length && !this.groups.length) {
371
+ return html`<div class="emptyNote">${this.emptyText}</div>`;
372
+ }
373
+ return html`
374
+ ${this.groups.map((group) => {
375
+ const members = group.sessionIds
376
+ .map((id) => byId.get(id))
377
+ .filter((session): session is IHarnessSessionMeta => session !== undefined);
378
+ const collapsed = this.collapsedGroupIds.has(group.id);
379
+ const isHeaderDrop =
380
+ this.dropSlot?.groupId === group.id && this.dropSlot.index === members.length && collapsed;
381
+ return html`
382
+ <button
383
+ class="groupHeader ${collapsed ? 'collapsed' : ''} ${isHeaderDrop ? 'dropTarget' : ''}"
384
+ type="button"
385
+ @click=${() => this.toggleGroupCollapsed(group.id)}
386
+ @contextmenu=${(event: MouseEvent) => {
387
+ event.preventDefault();
388
+ const detail: IHarnessGroupContextDetail = {
389
+ group,
390
+ clientX: event.clientX,
391
+ clientY: event.clientY,
392
+ originalEvent: event,
393
+ };
394
+ this.dispatchEvent(
395
+ new CustomEvent('harness-group-context', { detail, bubbles: true, composed: true }),
396
+ );
397
+ }}
398
+ @dragover=${(event: DragEvent) => this.handleDragOverSlot(event, group.id, members.length)}
399
+ @drop=${(event: DragEvent) => this.handleDrop(event, group.id, members.length)}
400
+ >
401
+ <dees-icon class="chevron" icon="lucide:ChevronDown" iconSize="12"></dees-icon>
402
+ <span>${group.name}</span>
403
+ <span class="groupCount">${members.length}</span>
404
+ </button>
405
+ ${collapsed
406
+ ? ''
407
+ : this.renderSection(members, group.id)}
408
+ `;
409
+ })}
410
+ ${this.groups.length && ungrouped.length
411
+ ? html`
412
+ <button
413
+ class="groupHeader"
414
+ type="button"
415
+ tabindex="-1"
416
+ @dragover=${(event: DragEvent) => this.handleDragOverSlot(event, null, 0)}
417
+ @drop=${(event: DragEvent) => this.handleDrop(event, null, 0)}
418
+ >
419
+ <span>Ungrouped</span>
420
+ <span class="groupCount">${ungrouped.length}</span>
421
+ </button>
422
+ `
423
+ : ''}
424
+ ${this.renderSection(ungrouped, null)}
425
+ `;
426
+ }
427
+
428
+ private renderSection(
429
+ sessions: IHarnessSessionMeta[],
430
+ groupId: string | null,
431
+ ): TemplateResult {
432
+ const slot = this.dropSlot;
433
+ return html`
434
+ ${sessions.map(
435
+ (session, index) => html`
436
+ ${slot && slot.groupId === groupId && slot.index === index
437
+ ? html`<div class="dropLine"></div>`
438
+ : ''}
439
+ ${this.renderItem(session, groupId, index)}
440
+ `,
441
+ )}
442
+ ${slot && slot.groupId === groupId && slot.index === sessions.length
443
+ ? html`<div class="dropLine"></div>`
444
+ : ''}
195
445
  `;
196
446
  }
197
447
 
198
- private renderItem(session: IHarnessSessionMeta): TemplateResult {
448
+ private renderItem(
449
+ session: IHarnessSessionMeta,
450
+ groupId: string | null,
451
+ index: number,
452
+ ): TemplateResult {
453
+ const draggable = this.enableGrouping && index >= 0;
199
454
  return html`
200
455
  <button
201
- class="item ${session.id === this.selectedSessionId ? 'selected' : ''}"
456
+ class="item ${session.id === this.selectedSessionId ? 'selected' : ''} ${
457
+ this.draggingSessionId === session.id ? 'dragging' : ''
458
+ }"
202
459
  type="button"
460
+ draggable=${draggable ? 'true' : 'false'}
203
461
  @click=${() => this.emit('harness-session-select', session)}
204
462
  @dblclick=${() => this.emit('harness-session-open', session)}
205
463
  @keydown=${(event: KeyboardEvent) => {
@@ -217,6 +475,29 @@ export class DeesHarnessSessionList extends DeesElement {
217
475
  new CustomEvent('harness-session-context', { detail, bubbles: true, composed: true }),
218
476
  );
219
477
  }}
478
+ @dragstart=${(event: DragEvent) => {
479
+ if (!draggable) return;
480
+ this.draggingSessionId = session.id;
481
+ event.dataTransfer?.setData('text/plain', session.id);
482
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
483
+ }}
484
+ @dragend=${() => {
485
+ this.draggingSessionId = '';
486
+ this.dropSlot = undefined;
487
+ }}
488
+ @dragover=${(event: DragEvent) => {
489
+ if (!this.draggingSessionId || index < 0) return;
490
+ event.preventDefault();
491
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
492
+ const before = event.clientY < rect.top + rect.height / 2;
493
+ this.setDropSlot(groupId, before ? index : index + 1);
494
+ }}
495
+ @drop=${(event: DragEvent) => {
496
+ if (index < 0) return;
497
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
498
+ const before = event.clientY < rect.top + rect.height / 2;
499
+ this.handleDrop(event, groupId, before ? index : index + 1);
500
+ }}
220
501
  >
221
502
  <span class="itemTitle">
222
503
  <span class="titleText">${session.title || session.id}</span>
@@ -229,6 +510,45 @@ export class DeesHarnessSessionList extends DeesElement {
229
510
  `;
230
511
  }
231
512
 
513
+ private toggleGroupCollapsed(groupIdArg: string): void {
514
+ const next = new Set(this.collapsedGroupIds);
515
+ if (next.has(groupIdArg)) {
516
+ next.delete(groupIdArg);
517
+ } else {
518
+ next.add(groupIdArg);
519
+ }
520
+ this.collapsedGroupIds = next;
521
+ }
522
+
523
+ private setDropSlot(groupIdArg: string | null, indexArg: number): void {
524
+ const current = this.dropSlot;
525
+ if (current && current.groupId === groupIdArg && current.index === indexArg) return;
526
+ this.dropSlot = { groupId: groupIdArg, index: indexArg };
527
+ }
528
+
529
+ private handleDragOverSlot(event: DragEvent, groupIdArg: string | null, indexArg: number): void {
530
+ if (!this.draggingSessionId) return;
531
+ event.preventDefault();
532
+ this.setDropSlot(groupIdArg, indexArg);
533
+ }
534
+
535
+ private handleDrop(event: DragEvent, groupIdArg: string | null, indexArg: number): void {
536
+ event.preventDefault();
537
+ event.stopPropagation();
538
+ const sessionId = this.draggingSessionId || event.dataTransfer?.getData('text/plain') || '';
539
+ this.draggingSessionId = '';
540
+ this.dropSlot = undefined;
541
+ if (!sessionId) return;
542
+ const detail: IHarnessSessionMoveDetail = {
543
+ sessionId,
544
+ groupId: groupIdArg,
545
+ index: Math.max(0, indexArg),
546
+ };
547
+ this.dispatchEvent(
548
+ new CustomEvent('harness-session-move', { detail, bubbles: true, composed: true }),
549
+ );
550
+ }
551
+
232
552
  private emit(name: string, session: IHarnessSessionMeta): void {
233
553
  this.dispatchEvent(new CustomEvent(name, { detail: { session }, bubbles: true, composed: true }));
234
554
  }
@@ -210,6 +210,30 @@ export interface IHarnessSessionContextDetail {
210
210
  originalEvent: MouseEvent;
211
211
  }
212
212
 
213
+ /** A user-defined session group; ordering of `sessionIds` is the display order. */
214
+ export interface IHarnessSessionGroup {
215
+ id: string;
216
+ name: string;
217
+ sessionIds: string[];
218
+ }
219
+
220
+ /** Detail of `harness-session-move`: a session was dropped into a group slot. */
221
+ export interface IHarnessSessionMoveDetail {
222
+ sessionId: string;
223
+ /** Target group, or null for the ungrouped section. */
224
+ groupId: string | null;
225
+ /** Insertion index within the target section's current display order. */
226
+ index: number;
227
+ }
228
+
229
+ /** Detail of `harness-group-context`, fired on right-click of a group header. */
230
+ export interface IHarnessGroupContextDetail {
231
+ group: IHarnessSessionGroup;
232
+ clientX: number;
233
+ clientY: number;
234
+ originalEvent: MouseEvent;
235
+ }
236
+
213
237
  export type THarnessTodoStatus = 'pending' | 'in_progress' | 'completed';
214
238
 
215
239
  export interface IHarnessTodoItem {