@design.estate/dees-catalog 4.9.0 → 4.10.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.
@@ -8,6 +8,7 @@ import {
8
8
  property,
9
9
  state,
10
10
  cssManager,
11
+ directives,
11
12
  } from '@design.estate/dees-element';
12
13
  import { themeDefaultStyles } from '../../00theme.js';
13
14
  import type {
@@ -20,6 +21,8 @@ import type {
20
21
  import { harnessFormatRelativeTime } from '../harness.helpers.js';
21
22
  import '../../00group-utility/dees-icon/dees-icon.js';
22
23
 
24
+ const { repeat } = directives;
25
+
23
26
  declare global {
24
27
  interface HTMLElementTagNameMap {
25
28
  'dees-harness-session-list': DeesHarnessSessionList;
@@ -31,6 +34,10 @@ interface IDropSlot {
31
34
  index: number;
32
35
  }
33
36
 
37
+ type TSectionRenderEntry =
38
+ | { type: 'session'; key: string; session: IHarnessSessionMeta; logicalIndex: number }
39
+ | { type: 'placeholder'; key: string };
40
+
34
41
  /**
35
42
  * Searchable conversation list, embeddable in a sidebar or a DeesModal.
36
43
  * Emits `harness-session-select` on click, `harness-session-open` on
@@ -43,7 +50,8 @@ interface IDropSlot {
43
50
  * `harness-group-create-request`, and right-clicking a group header emits
44
51
  * `harness-group-context` — the host persists changes and passes fresh
45
52
  * `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.
53
+ * section with pinned entries first, then explicit order, then new sessions by
54
+ * recency. While a search query is active the list is flat.
47
55
  */
48
56
  @customElement('dees-harness-session-list')
49
57
  export class DeesHarnessSessionList extends DeesElement {
@@ -68,6 +76,10 @@ export class DeesHarnessSessionList extends DeesElement {
68
76
  @property({ type: Boolean })
69
77
  accessor enableGrouping: boolean = false;
70
78
 
79
+ /** Explicit order for sortable sessions outside user-defined groups. */
80
+ @property({ attribute: false })
81
+ accessor ungroupedSessionIds: string[] = [];
82
+
71
83
  @state()
72
84
  accessor search: string = '';
73
85
 
@@ -77,9 +89,19 @@ export class DeesHarnessSessionList extends DeesElement {
77
89
  @state()
78
90
  accessor dropSlot: IDropSlot | undefined = undefined;
79
91
 
92
+ @state()
93
+ accessor draggedItemHeight = 0;
94
+
95
+ @state()
96
+ accessor expandedSessionIds: ReadonlySet<string> = new Set<string>();
97
+
80
98
  @state()
81
99
  accessor collapsedGroupIds: ReadonlySet<string> = new Set<string>();
82
100
 
101
+ private reflowGeneration = 0;
102
+ private readonly reflowAnimations = new Set<Animation>();
103
+ private dragSourceElement: HTMLElement | undefined;
104
+
83
105
  public static styles = [
84
106
  themeDefaultStyles,
85
107
  cssManager.defaultStyles,
@@ -89,26 +111,48 @@ export class DeesHarnessSessionList extends DeesElement {
89
111
  flex-direction: column;
90
112
  min-height: 0;
91
113
  font-family: var(--dees-font-family);
114
+ --session-normal: var(--dees-color-text-muted);
115
+ --session-working: var(--dees-color-accent-primary);
116
+ --session-finished: var(--dees-color-accent-success);
117
+ --session-attention: #bf5af2;
118
+ --session-error: var(--dees-color-accent-error);
119
+ }
120
+
121
+ :host([gobright]) {
122
+ --session-attention: #af52de;
92
123
  }
93
124
 
94
125
  .searchRow {
126
+ position: relative;
95
127
  flex: 0 0 auto;
96
- padding: var(--dees-spacing-sm);
128
+ padding: var(--dees-spacing-sm) var(--dees-spacing-md) var(--dees-spacing-md);
129
+ }
130
+
131
+ .searchIcon {
132
+ position: absolute;
133
+ top: 50%;
134
+ left: calc(var(--dees-spacing-md) + 10px);
135
+ color: var(--dees-color-text-muted);
136
+ transform: translateY(-62%);
137
+ pointer-events: none;
97
138
  }
98
139
 
99
140
  .searchRow input {
100
141
  width: 100%;
101
- height: var(--dees-control-height-sm);
102
- padding: 0 var(--dees-spacing-md);
142
+ height: var(--dees-control-height-lg);
143
+ padding: 0 var(--dees-spacing-md) 0 34px;
103
144
  border: 1px solid var(--dees-color-border-default);
104
- border-radius: var(--dees-radius-md);
145
+ border-radius: var(--dees-radius-lg);
105
146
  corner-shape: var(--dees-corner-shape);
106
- background: var(--dees-color-bg-primary);
147
+ background: color-mix(in srgb, var(--dees-color-bg-primary) 78%, transparent);
107
148
  color: var(--dees-color-text-primary);
108
149
  font-family: inherit;
109
- font-size: var(--dees-font-control-size, 13px);
150
+ font-size: var(--dees-font-control-size-sm, 12px);
110
151
  outline: none;
111
152
  box-sizing: border-box;
153
+ box-shadow: var(--dees-shadow-xs);
154
+ transition: border-color var(--dees-transition-fast) var(--dees-ease-standard),
155
+ box-shadow var(--dees-transition-fast) var(--dees-ease-standard);
112
156
  }
113
157
 
114
158
  .searchRow input:focus {
@@ -122,46 +166,146 @@ export class DeesHarnessSessionList extends DeesElement {
122
166
  overflow-y: auto;
123
167
  scrollbar-width: thin;
124
168
  scrollbar-color: var(--dees-color-scrollbar-thumb) transparent;
125
- padding: 0 var(--dees-spacing-xs) var(--dees-spacing-xs);
169
+ padding: 0 var(--dees-spacing-md) var(--dees-spacing-md);
170
+ }
171
+
172
+ .section {
173
+ position: relative;
126
174
  display: grid;
127
- gap: 2px;
175
+ gap: var(--dees-spacing-sm);
128
176
  align-content: start;
129
177
  }
130
178
 
131
179
  .item {
180
+ --card-accent: var(--session-normal);
181
+ position: relative;
132
182
  width: 100%;
133
- display: block;
134
- padding: var(--dees-spacing-xs) var(--dees-spacing-sm);
135
- border: 0;
136
- border-radius: var(--dees-radius-md);
183
+ min-width: 0;
184
+ overflow: hidden;
185
+ border: 1px solid color-mix(in srgb, var(--card-accent) 24%, var(--dees-color-border-subtle));
186
+ border-radius: var(--dees-radius-xl);
137
187
  corner-shape: var(--dees-corner-shape);
138
- background: transparent;
188
+ background: color-mix(in srgb, var(--dees-color-bg-primary) 90%, transparent);
139
189
  color: var(--dees-color-text-primary);
140
- cursor: pointer;
141
- font-family: inherit;
142
- text-align: left;
143
- transition: background var(--dees-transition-fast) var(--dees-ease-standard);
190
+ box-shadow:
191
+ var(--dees-shadow-sm),
192
+ inset 3px 0 0 var(--card-accent);
193
+ box-sizing: border-box;
194
+ transition:
195
+ border-color var(--dees-transition-fast) var(--dees-ease-standard),
196
+ background var(--dees-transition-fast) var(--dees-ease-standard),
197
+ box-shadow var(--dees-transition-fast) var(--dees-ease-standard);
144
198
  }
145
199
 
146
200
  .item:hover {
147
- background: var(--dees-color-hover);
201
+ border-color: color-mix(in srgb, var(--card-accent) 42%, var(--dees-color-border-default));
202
+ background: color-mix(in srgb, var(--dees-color-bg-primary) 96%, var(--card-accent) 4%);
148
203
  }
149
204
 
150
205
  .item.selected {
151
- background: var(--dees-color-active);
152
- box-shadow: inset 2px 0 0 var(--dees-color-accent-primary);
206
+ border-color: color-mix(in srgb, var(--dees-color-accent-primary) 65%, var(--dees-color-border-default));
207
+ box-shadow:
208
+ 0 0 0 2px color-mix(in srgb, var(--dees-color-accent-primary) 18%, transparent),
209
+ var(--dees-shadow-sm),
210
+ inset 3px 0 0 var(--card-accent);
153
211
  }
154
212
 
155
213
  .item.dragging {
156
- opacity: 0.4;
214
+ position: absolute;
215
+ inset-inline: 0;
216
+ z-index: -1;
217
+ opacity: 0;
218
+ pointer-events: none;
157
219
  }
158
220
 
159
- .itemTitle {
221
+ .item.state-working {
222
+ --card-accent: var(--session-working);
223
+ }
224
+
225
+ .item.state-finished {
226
+ --card-accent: var(--session-finished);
227
+ }
228
+
229
+ .item.state-attention {
230
+ --card-accent: var(--session-attention);
231
+ }
232
+
233
+ .item.state-error {
234
+ --card-accent: var(--session-error);
235
+ }
236
+
237
+ @keyframes attentionPulse {
238
+ 0%, 100% {
239
+ box-shadow:
240
+ 0 0 0 0 color-mix(in srgb, var(--session-attention) 0%, transparent),
241
+ var(--dees-shadow-sm),
242
+ inset 3px 0 0 var(--session-attention);
243
+ }
244
+ 50% {
245
+ box-shadow:
246
+ 0 0 0 4px color-mix(in srgb, var(--session-attention) 26%, transparent),
247
+ 0 7px 18px color-mix(in srgb, var(--session-attention) 16%, transparent),
248
+ inset 3px 0 0 var(--session-attention);
249
+ }
250
+ }
251
+
252
+ .item.state-attention:not(.selected) {
253
+ animation: attentionPulse 1.15s ease-in-out infinite;
254
+ }
255
+
256
+ .itemHeader {
257
+ display: grid;
258
+ grid-template-columns: minmax(0, 1fr) 30px;
259
+ min-height: 46px;
260
+ }
261
+
262
+ .itemPrimary,
263
+ .expandToggle {
264
+ appearance: none;
265
+ border: 0;
266
+ background: transparent;
267
+ color: inherit;
268
+ font-family: inherit;
269
+ }
270
+
271
+ .itemPrimary {
160
272
  display: flex;
161
- align-items: baseline;
273
+ min-width: 0;
274
+ align-items: center;
162
275
  gap: var(--dees-spacing-sm);
276
+ padding: var(--dees-spacing-sm) var(--dees-spacing-xs) var(--dees-spacing-sm) var(--dees-spacing-md);
277
+ text-align: left;
278
+ cursor: pointer;
279
+ }
280
+
281
+ .itemPrimary:focus-visible,
282
+ .expandToggle:focus-visible {
283
+ outline: 2px solid var(--dees-color-accent-primary);
284
+ outline-offset: -3px;
285
+ border-radius: 9px;
286
+ }
287
+
288
+ .stateDot {
289
+ width: 8px;
290
+ height: 8px;
291
+ flex: 0 0 auto;
292
+ border-radius: 50%;
293
+ background: var(--card-accent);
294
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--card-accent) 15%, transparent);
295
+ }
296
+
297
+ .itemCopy {
298
+ min-width: 0;
299
+ flex: 1;
300
+ }
301
+
302
+ .itemTitle {
303
+ display: flex;
304
+ min-width: 0;
305
+ align-items: center;
306
+ gap: 6px;
163
307
  font-size: var(--dees-font-control-size-sm, 12px);
164
- font-weight: 500;
308
+ font-weight: 590;
165
309
  }
166
310
 
167
311
  .itemTitle .titleText {
@@ -173,24 +317,99 @@ export class DeesHarnessSessionList extends DeesElement {
173
317
 
174
318
  .itemTitle .itemIcon {
175
319
  flex: 0 0 auto;
176
- align-self: center;
177
320
  color: var(--dees-color-text-secondary);
178
321
  }
179
322
 
180
323
  .itemTime {
181
- margin-left: auto;
182
- flex: 0 0 auto;
324
+ margin-top: 2px;
183
325
  color: var(--dees-color-text-muted);
184
326
  font-size: var(--dees-font-caption2-size, 10px);
185
327
  }
186
328
 
329
+ .expandToggle {
330
+ display: grid;
331
+ place-items: center;
332
+ margin: 7px 7px 7px 0;
333
+ border-radius: 9px;
334
+ color: var(--dees-color-text-muted);
335
+ cursor: pointer;
336
+ }
337
+
338
+ .expandToggle:hover {
339
+ background: color-mix(in srgb, var(--card-accent) 11%, transparent);
340
+ color: var(--dees-color-text-primary);
341
+ }
342
+
343
+ .expandToggle dees-icon {
344
+ transition: transform var(--dees-transition-fast) var(--dees-ease-standard);
345
+ }
346
+
347
+ .item.expanded .expandToggle dees-icon {
348
+ transform: rotate(180deg);
349
+ }
350
+
351
+ .itemDetails {
352
+ display: grid;
353
+ gap: 6px;
354
+ margin: 0 10px 10px 28px;
355
+ padding: 9px 10px;
356
+ border-top: 1px solid color-mix(in srgb, var(--card-accent) 20%, var(--dees-color-border-subtle));
357
+ color: var(--dees-color-text-secondary);
358
+ }
359
+
360
+ .stateLine {
361
+ display: flex;
362
+ align-items: center;
363
+ gap: 6px;
364
+ color: var(--card-accent);
365
+ font-size: var(--dees-font-caption1-size, 11px);
366
+ font-weight: 650;
367
+ }
368
+
187
369
  .itemPreview {
188
- margin-top: 1px;
189
370
  color: var(--dees-color-text-secondary);
190
371
  font-size: var(--dees-font-caption1-size, 11px);
191
372
  overflow: hidden;
192
- text-overflow: ellipsis;
373
+ display: -webkit-box;
374
+ -webkit-line-clamp: 2;
375
+ -webkit-box-orient: vertical;
376
+ }
377
+
378
+ .itemMeta {
379
+ color: var(--dees-color-text-muted);
380
+ font-size: var(--dees-font-caption2-size, 10px);
381
+ }
382
+
383
+ .visuallyHidden {
384
+ position: absolute;
385
+ width: 1px;
386
+ height: 1px;
387
+ padding: 0;
388
+ margin: -1px;
389
+ overflow: hidden;
390
+ clip: rect(0, 0, 0, 0);
193
391
  white-space: nowrap;
392
+ border: 0;
393
+ }
394
+
395
+ .dropPlaceholder,
396
+ .emptyDropZone {
397
+ display: grid;
398
+ place-items: center;
399
+ min-height: 46px;
400
+ border: 1px dashed color-mix(in srgb, var(--dees-color-accent-primary) 68%, transparent);
401
+ border-radius: var(--dees-radius-xl);
402
+ background: color-mix(in srgb, var(--dees-color-accent-primary) 8%, transparent);
403
+ color: var(--dees-color-accent-primary);
404
+ box-sizing: border-box;
405
+ }
406
+
407
+ .dropPlaceholder::after,
408
+ .emptyDropZone::after {
409
+ content: 'Drop conversation here';
410
+ font-size: var(--dees-font-caption2-size, 10px);
411
+ font-weight: 650;
412
+ letter-spacing: 0.02em;
194
413
  }
195
414
 
196
415
  .emptyNote {
@@ -205,8 +424,8 @@ export class DeesHarnessSessionList extends DeesElement {
205
424
  align-items: center;
206
425
  gap: var(--dees-spacing-xs);
207
426
  width: 100%;
208
- margin-top: var(--dees-spacing-xs);
209
- padding: 2px var(--dees-spacing-sm);
427
+ margin: var(--dees-spacing-md) 0 7px;
428
+ padding: var(--dees-spacing-xs) 6px;
210
429
  border: 0;
211
430
  border-radius: var(--dees-radius-md);
212
431
  background: transparent;
@@ -246,15 +465,12 @@ export class DeesHarnessSessionList extends DeesElement {
246
465
  }
247
466
 
248
467
  .dropLine {
249
- height: 2px;
250
- margin: 0 var(--dees-spacing-sm);
251
- border-radius: 1px;
252
- background: var(--dees-color-accent-primary);
468
+ display: none;
253
469
  }
254
470
 
255
471
  .newGroupRow {
256
472
  flex: 0 0 auto;
257
- padding: var(--dees-spacing-xs) var(--dees-spacing-sm) var(--dees-spacing-sm);
473
+ padding: 0 var(--dees-spacing-md) var(--dees-spacing-md);
258
474
  }
259
475
 
260
476
  .newGroupButton {
@@ -262,10 +478,12 @@ export class DeesHarnessSessionList extends DeesElement {
262
478
  align-items: center;
263
479
  gap: var(--dees-spacing-xs);
264
480
  width: 100%;
481
+ min-height: 34px;
482
+ justify-content: center;
265
483
  padding: var(--dees-spacing-xs) var(--dees-spacing-sm);
266
484
  border: 1px dashed var(--dees-color-border-default);
267
- border-radius: var(--dees-radius-md);
268
- background: transparent;
485
+ border-radius: 11px;
486
+ background: color-mix(in srgb, var(--dees-color-bg-primary) 55%, transparent);
269
487
  color: var(--dees-color-text-secondary);
270
488
  cursor: pointer;
271
489
  font-family: inherit;
@@ -276,6 +494,15 @@ export class DeesHarnessSessionList extends DeesElement {
276
494
  border-color: var(--dees-color-accent-primary);
277
495
  color: var(--dees-color-text-primary);
278
496
  }
497
+
498
+ @media (prefers-reduced-motion: reduce) {
499
+ .item,
500
+ .item.state-attention,
501
+ .expandToggle dees-icon {
502
+ animation: none;
503
+ transition: none;
504
+ }
505
+ }
279
506
  `,
280
507
  ];
281
508
 
@@ -291,15 +518,26 @@ export class DeesHarnessSessionList extends DeesElement {
291
518
  return ids;
292
519
  }
293
520
 
294
- /** Ungrouped sessions, most recent first. */
521
+ /** Pinned entries first, followed by explicitly ordered sortable sessions. */
295
522
  private get ungroupedSessions(): IHarnessSessionMeta[] {
296
523
  const grouped = this.groupedSessionIds;
297
- return [...this.sessions]
298
- .filter((session) => !grouped.has(session.id))
299
- .sort(
300
- (left, right) =>
301
- (right.updatedAt ?? right.createdAt ?? 0) - (left.updatedAt ?? left.createdAt ?? 0),
302
- );
524
+ const available = this.sessions.filter((session) => !grouped.has(session.id));
525
+ const pinned = available.filter((session) => session.draggable === false);
526
+ const sortableById = new Map(
527
+ available.filter((session) => session.draggable !== false).map((session) => [session.id, session]),
528
+ );
529
+ const ordered: IHarnessSessionMeta[] = [];
530
+ for (const sessionId of this.ungroupedSessionIds) {
531
+ const session = sortableById.get(sessionId);
532
+ if (!session) continue;
533
+ ordered.push(session);
534
+ sortableById.delete(sessionId);
535
+ }
536
+ const newSessions = [...sortableById.values()].sort(
537
+ (left, right) =>
538
+ (right.updatedAt ?? right.createdAt ?? 0) - (left.updatedAt ?? left.createdAt ?? 0),
539
+ );
540
+ return [...pinned, ...ordered, ...newSessions];
303
541
  }
304
542
 
305
543
  private get filteredSessions(): IHarnessSessionMeta[] {
@@ -317,10 +555,45 @@ export class DeesHarnessSessionList extends DeesElement {
317
555
  this.shadowRoot?.querySelector<HTMLInputElement>('.searchRow input')?.focus();
318
556
  }
319
557
 
558
+ public updated(changedProperties: Map<PropertyKey, unknown>): void {
559
+ super.updated(changedProperties);
560
+ if (this.draggingSessionId && !this.dragSourceElement?.isConnected) {
561
+ this.clearDragState();
562
+ }
563
+ if (!changedProperties.has('sessions')) return;
564
+ const previousSessions = changedProperties.get('sessions') as IHarnessSessionMeta[] | undefined;
565
+ const previousById = new Map((previousSessions ?? []).map((session) => [session.id, session]));
566
+ const knownIds = new Set(this.sessions.map((session) => session.id));
567
+ if (this.draggingSessionId && !knownIds.has(this.draggingSessionId)) {
568
+ this.clearDragState();
569
+ }
570
+ const nextExpanded = new Set(
571
+ [...this.expandedSessionIds].filter((sessionId) => knownIds.has(sessionId)),
572
+ );
573
+ for (const session of this.sessions) {
574
+ const previous = previousById.get(session.id);
575
+ if (previous && previous.working !== true && session.working === true) {
576
+ nextExpanded.add(session.id);
577
+ }
578
+ }
579
+ if (
580
+ nextExpanded.size !== this.expandedSessionIds.size
581
+ || [...nextExpanded].some((sessionId) => !this.expandedSessionIds.has(sessionId))
582
+ ) {
583
+ this.expandedSessionIds = nextExpanded;
584
+ }
585
+ }
586
+
587
+ public async disconnectedCallback(): Promise<void> {
588
+ this.clearDragState();
589
+ await super.disconnectedCallback();
590
+ }
591
+
320
592
  public render(): TemplateResult {
321
593
  const groupingActive = this.enableGrouping && !this.search.trim();
322
594
  return html`
323
595
  <div class="searchRow">
596
+ <dees-icon class="searchIcon" icon="lucide:Search" iconSize="13"></dees-icon>
324
597
  <input
325
598
  placeholder="Search conversations"
326
599
  .value=${this.search}
@@ -367,7 +640,15 @@ export class DeesHarnessSessionList extends DeesElement {
367
640
  ${this.sessions.length ? 'No conversations match this search.' : this.emptyText}
368
641
  </div>`;
369
642
  }
370
- return html`${sessions.map((session) => this.renderItem(session, null, -1))}`;
643
+ return html`
644
+ <div class="section">
645
+ ${repeat(
646
+ sessions,
647
+ (session) => session.id,
648
+ (session) => this.renderItem(session, null, -1),
649
+ )}
650
+ </div>
651
+ `;
371
652
  }
372
653
 
373
654
  private renderGroupedList(): TemplateResult {
@@ -383,7 +664,9 @@ export class DeesHarnessSessionList extends DeesElement {
383
664
  .filter((session): session is IHarnessSessionMeta => session !== undefined);
384
665
  const collapsed = this.collapsedGroupIds.has(group.id);
385
666
  const isHeaderDrop =
386
- this.dropSlot?.groupId === group.id && this.dropSlot.index === members.length && collapsed;
667
+ this.dropSlot?.groupId === group.id
668
+ && this.dropSlot.index === this.sortableSessions(members).length
669
+ && collapsed;
387
670
  return html`
388
671
  <button
389
672
  class="groupHeader ${collapsed ? 'collapsed' : ''} ${isHeaderDrop ? 'dropTarget' : ''}"
@@ -401,8 +684,16 @@ export class DeesHarnessSessionList extends DeesElement {
401
684
  new CustomEvent('harness-group-context', { detail, bubbles: true, composed: true }),
402
685
  );
403
686
  }}
404
- @dragover=${(event: DragEvent) => this.handleDragOverSlot(event, group.id, members.length)}
405
- @drop=${(event: DragEvent) => this.handleDrop(event, group.id, members.length)}
687
+ @dragover=${(event: DragEvent) => this.handleDragOverSlot(
688
+ event,
689
+ group.id,
690
+ this.sortableSessions(members).length,
691
+ )}
692
+ @drop=${(event: DragEvent) => this.handleDrop(
693
+ event,
694
+ group.id,
695
+ this.sortableSessions(members).length,
696
+ )}
406
697
  >
407
698
  <dees-icon class="chevron" icon="lucide:ChevronDown" iconSize="12"></dees-icon>
408
699
  <span>${group.name}</span>
@@ -413,7 +704,7 @@ export class DeesHarnessSessionList extends DeesElement {
413
704
  : this.renderSection(members, group.id)}
414
705
  `;
415
706
  })}
416
- ${this.groups.length && ungrouped.length
707
+ ${this.groups.length && (ungrouped.length || this.draggingSessionId)
417
708
  ? html`
418
709
  <button
419
710
  class="groupHeader"
@@ -436,18 +727,62 @@ export class DeesHarnessSessionList extends DeesElement {
436
727
  groupId: string | null,
437
728
  ): TemplateResult {
438
729
  const slot = this.dropSlot;
730
+ const entries: TSectionRenderEntry[] = [];
731
+ let logicalIndex = 0;
732
+ for (const session of sessions) {
733
+ const isDragging = session.id === this.draggingSessionId;
734
+ const sortable = session.draggable !== false;
735
+ if (
736
+ slot
737
+ && slot.groupId === groupId
738
+ && slot.index === logicalIndex
739
+ && !isDragging
740
+ && sortable
741
+ ) {
742
+ entries.push({ type: 'placeholder', key: `placeholder:${groupId ?? 'ungrouped'}` });
743
+ }
744
+ entries.push({
745
+ type: 'session',
746
+ key: `session:${session.id}`,
747
+ session,
748
+ logicalIndex: sortable ? logicalIndex : -1,
749
+ });
750
+ if (sortable && !isDragging) logicalIndex += 1;
751
+ }
752
+ if (slot && slot.groupId === groupId && slot.index === logicalIndex) {
753
+ entries.push({ type: 'placeholder', key: `placeholder:${groupId ?? 'ungrouped'}` });
754
+ }
755
+ const hasPlaceholder = entries.some((entry) => entry.type === 'placeholder');
439
756
  return html`
440
- ${sessions.map(
441
- (session, index) => html`
442
- ${slot && slot.groupId === groupId && slot.index === index
443
- ? html`<div class="dropLine"></div>`
444
- : ''}
445
- ${this.renderItem(session, groupId, index)}
446
- `,
447
- )}
448
- ${slot && slot.groupId === groupId && slot.index === sessions.length
449
- ? html`<div class="dropLine"></div>`
450
- : ''}
757
+ <div class="section" data-group-id=${groupId ?? ''}>
758
+ ${repeat(
759
+ entries,
760
+ (entry) => entry.key,
761
+ (entry) => entry.type === 'placeholder'
762
+ ? this.renderDropPlaceholder(groupId, slot?.index ?? 0)
763
+ : this.renderItem(entry.session, groupId, entry.logicalIndex),
764
+ )}
765
+ ${this.draggingSessionId && !hasPlaceholder && this.sortableSessions(sessions).length === 0
766
+ ? html`
767
+ <div
768
+ class="emptyDropZone"
769
+ @dragover=${(event: DragEvent) => this.handleDragOverSlot(event, groupId, 0)}
770
+ @drop=${(event: DragEvent) => this.handleDrop(event, groupId, 0)}
771
+ ></div>
772
+ `
773
+ : ''}
774
+ </div>
775
+ `;
776
+ }
777
+
778
+ private renderDropPlaceholder(groupId: string | null, index: number): TemplateResult {
779
+ return html`
780
+ <div
781
+ class="dropPlaceholder"
782
+ style=${`height: ${Math.max(46, this.draggedItemHeight)}px`}
783
+ @dragover=${(event: DragEvent) => this.handleDragOverSlot(event, groupId, index)}
784
+ @drop=${(event: DragEvent) => this.handleDrop(event, groupId, index)}
785
+ ></div>
451
786
  `;
452
787
  }
453
788
 
@@ -456,20 +791,30 @@ export class DeesHarnessSessionList extends DeesElement {
456
791
  groupId: string | null,
457
792
  index: number,
458
793
  ): TemplateResult {
459
- const draggable = this.enableGrouping && index >= 0;
794
+ const draggable = (
795
+ this.enableGrouping
796
+ && session.draggable !== false
797
+ && index >= 0
798
+ && !globalThis.matchMedia?.('(pointer: coarse)').matches
799
+ );
800
+ const expanded = this.expandedSessionIds.has(session.id);
801
+ const state = session.state ?? 'normal';
802
+ const detailsId = `session-details-${session.id}`;
460
803
  return html`
461
- <button
462
- class="item ${session.id === this.selectedSessionId ? 'selected' : ''} ${
804
+ <article
805
+ class="item state-${state} ${expanded ? 'expanded' : ''} ${
806
+ session.id === this.selectedSessionId ? 'selected' : ''
807
+ } ${
463
808
  this.draggingSessionId === session.id ? 'dragging' : ''
464
809
  }"
465
- type="button"
466
- draggable=${draggable ? 'true' : 'false'}
467
- @click=${() => this.emit('harness-session-select', session)}
468
- @dblclick=${() => this.emit('harness-session-open', session)}
469
- @keydown=${(event: KeyboardEvent) => {
470
- if (event.key === 'Enter') this.emit('harness-session-open', session);
810
+ data-session-id=${session.id}
811
+ .draggable=${draggable}
812
+ @dblclick=${(event: MouseEvent) => {
813
+ if (this.eventTargetsExpandToggle(event)) return;
814
+ this.emit('harness-session-open', session);
471
815
  }}
472
816
  @contextmenu=${(event: MouseEvent) => {
817
+ if (this.eventTargetsExpandToggle(event)) return;
473
818
  event.preventDefault();
474
819
  const detail: IHarnessSessionContextDetail = {
475
820
  session,
@@ -482,43 +827,102 @@ export class DeesHarnessSessionList extends DeesElement {
482
827
  );
483
828
  }}
484
829
  @dragstart=${(event: DragEvent) => {
485
- if (!draggable) return;
486
- this.draggingSessionId = session.id;
487
- event.dataTransfer?.setData('text/plain', session.id);
488
- if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
489
- }}
490
- @dragend=${() => {
491
- this.draggingSessionId = '';
492
- this.dropSlot = undefined;
830
+ this.handleDragStart(event, session, groupId, index, draggable);
493
831
  }}
832
+ @dragend=${() => this.clearDragState()}
494
833
  @dragover=${(event: DragEvent) => {
495
- if (!this.draggingSessionId || index < 0) return;
834
+ if (!this.draggingSessionId || index < 0 || session.id === this.draggingSessionId) return;
496
835
  event.preventDefault();
497
836
  const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
498
837
  const before = event.clientY < rect.top + rect.height / 2;
499
838
  this.setDropSlot(groupId, before ? index : index + 1);
500
839
  }}
501
840
  @drop=${(event: DragEvent) => {
502
- if (index < 0) return;
841
+ if (index < 0 || session.id === this.draggingSessionId) return;
503
842
  const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
504
843
  const before = event.clientY < rect.top + rect.height / 2;
505
844
  this.handleDrop(event, groupId, before ? index : index + 1);
506
845
  }}
507
846
  >
508
- <span class="itemTitle">
509
- ${session.icon
510
- ? html`<dees-icon class="itemIcon" icon=${session.icon} iconSize="12"></dees-icon>`
511
- : ''}
512
- <span class="titleText">${session.title || session.id}</span>
513
- <span class="itemTime">${harnessFormatRelativeTime(session.updatedAt ?? session.createdAt)}</span>
514
- </span>
515
- ${session.preview
516
- ? html`<span class="itemPreview">${session.preview}${session.messageCount ? ` · ${session.messageCount} messages` : ''}</span>`
847
+ <div class="itemHeader">
848
+ <button
849
+ class="itemPrimary"
850
+ type="button"
851
+ aria-current=${session.id === this.selectedSessionId ? 'true' : 'false'}
852
+ @click=${() => this.emit('harness-session-select', session)}
853
+ @keydown=${(event: KeyboardEvent) => {
854
+ if (event.key !== 'Enter') return;
855
+ event.preventDefault();
856
+ this.emit('harness-session-open', session);
857
+ }}
858
+ >
859
+ <span class="stateDot"></span>
860
+ <span class="itemCopy">
861
+ <span class="itemTitle">
862
+ ${session.icon
863
+ ? html`<dees-icon class="itemIcon" icon=${session.icon} iconSize="13"></dees-icon>`
864
+ : ''}
865
+ <span class="titleText">${session.title || session.id}</span>
866
+ </span>
867
+ <span class="itemTime">${harnessFormatRelativeTime(session.updatedAt ?? session.createdAt)}</span>
868
+ <span class="visuallyHidden">Status: ${this.sessionStateLabel(state)}</span>
869
+ </span>
870
+ </button>
871
+ <button
872
+ class="expandToggle"
873
+ type="button"
874
+ aria-expanded=${expanded ? 'true' : 'false'}
875
+ aria-controls=${detailsId}
876
+ title=${expanded ? 'Collapse conversation card' : 'Expand conversation card'}
877
+ @click=${(event: MouseEvent) => {
878
+ event.stopPropagation();
879
+ this.toggleSessionExpanded(session.id);
880
+ }}
881
+ >
882
+ <dees-icon icon="lucide:ChevronDown" iconSize="15"></dees-icon>
883
+ </button>
884
+ </div>
885
+ ${expanded
886
+ ? html`
887
+ <div class="itemDetails" id=${detailsId}>
888
+ <div class="stateLine">
889
+ <span class="stateDot"></span>
890
+ <span>${this.sessionStateLabel(state)}</span>
891
+ </div>
892
+ ${session.preview ? html`<div class="itemPreview">${session.preview}</div>` : ''}
893
+ <div class="itemMeta">
894
+ ${session.messageCount ? `${session.messageCount} messages · ` : ''}${session.id}
895
+ </div>
896
+ </div>
897
+ `
517
898
  : ''}
518
- </button>
899
+ </article>
519
900
  `;
520
901
  }
521
902
 
903
+ private eventTargetsExpandToggle(event: Event): boolean {
904
+ return event.composedPath().some(
905
+ (target) => target instanceof HTMLElement && target.classList.contains('expandToggle'),
906
+ );
907
+ }
908
+
909
+ private sessionStateLabel(state: NonNullable<IHarnessSessionMeta['state']>): string {
910
+ switch (state) {
911
+ case 'working': return 'Working';
912
+ case 'finished': return 'Finished';
913
+ case 'attention': return 'Needs attention';
914
+ case 'error': return 'Error';
915
+ default: return 'Idle';
916
+ }
917
+ }
918
+
919
+ private toggleSessionExpanded(sessionIdArg: string): void {
920
+ const next = new Set(this.expandedSessionIds);
921
+ if (next.has(sessionIdArg)) next.delete(sessionIdArg);
922
+ else next.add(sessionIdArg);
923
+ this.expandedSessionIds = next;
924
+ }
925
+
522
926
  private toggleGroupCollapsed(groupIdArg: string): void {
523
927
  const next = new Set(this.collapsedGroupIds);
524
928
  if (next.has(groupIdArg)) {
@@ -529,15 +933,62 @@ export class DeesHarnessSessionList extends DeesElement {
529
933
  this.collapsedGroupIds = next;
530
934
  }
531
935
 
936
+ private sortableSessions(
937
+ sessionsArg: IHarnessSessionMeta[],
938
+ excludeDraggingArg = true,
939
+ ): IHarnessSessionMeta[] {
940
+ return sessionsArg.filter((session) => (
941
+ session.draggable !== false
942
+ && (!excludeDraggingArg || session.id !== this.draggingSessionId)
943
+ ));
944
+ }
945
+
946
+ private sessionsForGroup(groupIdArg: string | null): IHarnessSessionMeta[] {
947
+ if (groupIdArg === null) return this.ungroupedSessions;
948
+ const group = this.groups.find((candidate) => candidate.id === groupIdArg);
949
+ if (!group) return [];
950
+ const byId = this.sessionsById;
951
+ return group.sessionIds
952
+ .map((sessionId) => byId.get(sessionId))
953
+ .filter((session): session is IHarnessSessionMeta => session !== undefined);
954
+ }
955
+
956
+ private handleDragStart(
957
+ event: DragEvent,
958
+ session: IHarnessSessionMeta,
959
+ groupId: string | null,
960
+ logicalIndex: number,
961
+ draggable: boolean,
962
+ ): void {
963
+ if (!draggable || logicalIndex < 0) {
964
+ event.preventDefault();
965
+ return;
966
+ }
967
+ const item = event.currentTarget as HTMLElement;
968
+ this.reflowGeneration += 1;
969
+ this.cancelReflowAnimations();
970
+ this.draggedItemHeight = Math.ceil(item.getBoundingClientRect().height);
971
+ this.dragSourceElement = item;
972
+ this.draggingSessionId = session.id;
973
+ const remainingCount = this.sortableSessions(this.sessionsForGroup(groupId)).length;
974
+ this.dropSlot = { groupId, index: Math.min(logicalIndex, remainingCount) };
975
+ event.dataTransfer?.setData('text/plain', session.id);
976
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
977
+ }
978
+
532
979
  private setDropSlot(groupIdArg: string | null, indexArg: number): void {
533
980
  const current = this.dropSlot;
534
981
  if (current && current.groupId === groupIdArg && current.index === indexArg) return;
982
+ const previousRects = this.captureCardRects();
983
+ const generation = ++this.reflowGeneration;
535
984
  this.dropSlot = { groupId: groupIdArg, index: indexArg };
985
+ void this.animateCardReflow(previousRects, generation);
536
986
  }
537
987
 
538
988
  private handleDragOverSlot(event: DragEvent, groupIdArg: string | null, indexArg: number): void {
539
989
  if (!this.draggingSessionId) return;
540
990
  event.preventDefault();
991
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
541
992
  this.setDropSlot(groupIdArg, indexArg);
542
993
  }
543
994
 
@@ -545,19 +996,88 @@ export class DeesHarnessSessionList extends DeesElement {
545
996
  event.preventDefault();
546
997
  event.stopPropagation();
547
998
  const sessionId = this.draggingSessionId || event.dataTransfer?.getData('text/plain') || '';
548
- this.draggingSessionId = '';
549
- this.dropSlot = undefined;
550
- if (!sessionId) return;
999
+ if (!sessionId) {
1000
+ this.clearDragState();
1001
+ return;
1002
+ }
1003
+ const targetSessions = this.sortableSessions(this.sessionsForGroup(groupIdArg));
1004
+ const beforeSessionId = targetSessions[Math.max(0, indexArg)]?.id;
1005
+ const sourceGroupId = this.groups.find((group) => group.sessionIds.includes(sessionId))?.id ?? null;
1006
+ const sourceSessions = this.sortableSessions(this.sessionsForGroup(sourceGroupId), false);
1007
+ const sourceIndex = sourceSessions.findIndex((session) => session.id === sessionId);
1008
+ const compatibilityIndex = (
1009
+ sourceGroupId === groupIdArg
1010
+ && sourceIndex >= 0
1011
+ && indexArg > sourceIndex
1012
+ ) ? indexArg + 1 : indexArg;
551
1013
  const detail: IHarnessSessionMoveDetail = {
552
1014
  sessionId,
553
1015
  groupId: groupIdArg,
554
- index: Math.max(0, indexArg),
1016
+ index: Math.max(0, compatibilityIndex),
1017
+ ...(beforeSessionId ? { beforeSessionId } : {}),
555
1018
  };
1019
+ this.clearDragState();
556
1020
  this.dispatchEvent(
557
1021
  new CustomEvent('harness-session-move', { detail, bubbles: true, composed: true }),
558
1022
  );
559
1023
  }
560
1024
 
1025
+ private captureCardRects(): Map<string, DOMRect> {
1026
+ const result = new Map<string, DOMRect>();
1027
+ for (const item of this.shadowRoot?.querySelectorAll<HTMLElement>('.item[data-session-id]') ?? []) {
1028
+ if (item.dataset.sessionId === this.draggingSessionId) continue;
1029
+ result.set(item.dataset.sessionId ?? '', item.getBoundingClientRect());
1030
+ }
1031
+ return result;
1032
+ }
1033
+
1034
+ private async animateCardReflow(
1035
+ previousRectsArg: Map<string, DOMRect>,
1036
+ generationArg: number,
1037
+ ): Promise<void> {
1038
+ await this.updateComplete;
1039
+ if (
1040
+ generationArg !== this.reflowGeneration
1041
+ || !this.isConnected
1042
+ || globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches
1043
+ ) return;
1044
+ this.cancelReflowAnimations();
1045
+ for (const item of this.shadowRoot?.querySelectorAll<HTMLElement>('.item[data-session-id]') ?? []) {
1046
+ const sessionId = item.dataset.sessionId ?? '';
1047
+ if (!sessionId || sessionId === this.draggingSessionId) continue;
1048
+ const previous = previousRectsArg.get(sessionId);
1049
+ if (!previous) continue;
1050
+ const current = item.getBoundingClientRect();
1051
+ const deltaY = previous.top - current.top;
1052
+ if (Math.abs(deltaY) < 0.5) continue;
1053
+ const animation = item.animate(
1054
+ [
1055
+ { transform: `translateY(${deltaY}px)` },
1056
+ { transform: 'translateY(0)' },
1057
+ ],
1058
+ { duration: 180, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' },
1059
+ );
1060
+ this.reflowAnimations.add(animation);
1061
+ void animation.finished.catch(() => undefined).finally(() => {
1062
+ this.reflowAnimations.delete(animation);
1063
+ });
1064
+ }
1065
+ }
1066
+
1067
+ private cancelReflowAnimations(): void {
1068
+ for (const animation of this.reflowAnimations) animation.cancel();
1069
+ this.reflowAnimations.clear();
1070
+ }
1071
+
1072
+ private clearDragState(): void {
1073
+ this.reflowGeneration += 1;
1074
+ this.cancelReflowAnimations();
1075
+ this.draggingSessionId = '';
1076
+ this.draggedItemHeight = 0;
1077
+ this.dropSlot = undefined;
1078
+ this.dragSourceElement = undefined;
1079
+ }
1080
+
561
1081
  private emit(name: string, session: IHarnessSessionMeta): void {
562
1082
  this.dispatchEvent(new CustomEvent(name, { detail: { session }, bubbles: true, composed: true }));
563
1083
  }