@design.estate/dees-catalog 3.100.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 { 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,10 +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
- * Emits `harness-session-select` on click and `harness-session-open` on
26
- * double-click or Enter.
36
+ * Emits `harness-session-select` on click, `harness-session-open` on
37
+ * double-click or Enter, and `harness-session-context` with viewport
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.
27
47
  */
28
48
  @customElement('dees-harness-session-list')
29
49
  export class DeesHarnessSessionList extends DeesElement {
@@ -42,9 +62,24 @@ export class DeesHarnessSessionList extends DeesElement {
42
62
  @property()
43
63
  accessor emptyText: string = 'No conversations yet.';
44
64
 
65
+ @property({ attribute: false })
66
+ accessor groups: IHarnessSessionGroup[] = [];
67
+
68
+ @property({ type: Boolean })
69
+ accessor enableGrouping: boolean = false;
70
+
45
71
  @state()
46
72
  accessor search: string = '';
47
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
+
48
83
  public static styles = [
49
84
  themeDefaultStyles,
50
85
  cssManager.defaultStyles,
@@ -117,6 +152,10 @@ export class DeesHarnessSessionList extends DeesElement {
117
152
  box-shadow: inset 2px 0 0 var(--dees-color-accent-primary);
118
153
  }
119
154
 
155
+ .item.dragging {
156
+ opacity: 0.4;
157
+ }
158
+
120
159
  .itemTitle {
121
160
  display: flex;
122
161
  align-items: baseline;
@@ -154,9 +193,109 @@ export class DeesHarnessSessionList extends DeesElement {
154
193
  font-size: var(--dees-font-control-size-sm, 12px);
155
194
  text-align: center;
156
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
+ }
157
273
  `,
158
274
  ];
159
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
+
160
299
  private get filteredSessions(): IHarnessSessionMeta[] {
161
300
  const query = this.search.trim().toLowerCase();
162
301
  const sorted = [...this.sessions].sort(
@@ -173,7 +312,7 @@ export class DeesHarnessSessionList extends DeesElement {
173
312
  }
174
313
 
175
314
  public render(): TemplateResult {
176
- const sessions = this.filteredSessions;
315
+ const groupingActive = this.enableGrouping && !this.search.trim();
177
316
  return html`
178
317
  <div class="searchRow">
179
318
  <input
@@ -187,23 +326,178 @@ export class DeesHarnessSessionList extends DeesElement {
187
326
  <div class="list">
188
327
  ${this.loading
189
328
  ? html`<div class="emptyNote">Loading conversations…</div>`
190
- : sessions.length
191
- ? sessions.map((session) => this.renderItem(session))
192
- : html`<div class="emptyNote">${this.sessions.length ? 'No conversations match this search.' : this.emptyText}</div>`}
329
+ : groupingActive
330
+ ? this.renderGroupedList()
331
+ : this.renderFlatList()}
193
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
+ : ''}
194
445
  `;
195
446
  }
196
447
 
197
- 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;
198
454
  return html`
199
455
  <button
200
- class="item ${session.id === this.selectedSessionId ? 'selected' : ''}"
456
+ class="item ${session.id === this.selectedSessionId ? 'selected' : ''} ${
457
+ this.draggingSessionId === session.id ? 'dragging' : ''
458
+ }"
201
459
  type="button"
460
+ draggable=${draggable ? 'true' : 'false'}
202
461
  @click=${() => this.emit('harness-session-select', session)}
203
462
  @dblclick=${() => this.emit('harness-session-open', session)}
204
463
  @keydown=${(event: KeyboardEvent) => {
205
464
  if (event.key === 'Enter') this.emit('harness-session-open', session);
206
465
  }}
466
+ @contextmenu=${(event: MouseEvent) => {
467
+ event.preventDefault();
468
+ const detail: IHarnessSessionContextDetail = {
469
+ session,
470
+ clientX: event.clientX,
471
+ clientY: event.clientY,
472
+ originalEvent: event,
473
+ };
474
+ this.dispatchEvent(
475
+ new CustomEvent('harness-session-context', { detail, bubbles: true, composed: true }),
476
+ );
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
+ }}
207
501
  >
208
502
  <span class="itemTitle">
209
503
  <span class="titleText">${session.title || session.id}</span>
@@ -216,6 +510,45 @@ export class DeesHarnessSessionList extends DeesElement {
216
510
  `;
217
511
  }
218
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
+
219
552
  private emit(name: string, session: IHarnessSessionMeta): void {
220
553
  this.dispatchEvent(new CustomEvent(name, { detail: { session }, bubbles: true, composed: true }));
221
554
  }
@@ -200,6 +200,40 @@ export interface IHarnessSessionMeta {
200
200
  preview?: string;
201
201
  }
202
202
 
203
+ /** Detail of `harness-session-context`, fired on right-click of a session item. */
204
+ export interface IHarnessSessionContextDetail {
205
+ session: IHarnessSessionMeta;
206
+ /** Viewport coordinates of the pointer, for positioning a context menu. */
207
+ clientX: number;
208
+ clientY: number;
209
+ /** The suppressed contextmenu event, e.g. for DeesContextmenu.openContextMenuWithOptions. */
210
+ originalEvent: MouseEvent;
211
+ }
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
+
203
237
  export type THarnessTodoStatus = 'pending' | 'in_progress' | 'completed';
204
238
 
205
239
  export interface IHarnessTodoItem {