@design.estate/dees-catalog 4.9.0 → 4.11.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,10 @@ 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
+ const dragAutoScrollMaxPixelsPerSecond = 900;
26
+ const dragThresholdPixels = 4;
27
+
23
28
  declare global {
24
29
  interface HTMLElementTagNameMap {
25
30
  'dees-harness-session-list': DeesHarnessSessionList;
@@ -31,6 +36,29 @@ interface IDropSlot {
31
36
  index: number;
32
37
  }
33
38
 
39
+ interface IPointerDragGesture {
40
+ pointerId: number;
41
+ handle: HTMLElement;
42
+ source: HTMLElement;
43
+ sessionId: string;
44
+ sourceGroupId: string | null;
45
+ sourceLogicalIndex: number;
46
+ startClientX: number;
47
+ startClientY: number;
48
+ grabOffsetX: number;
49
+ grabOffsetY: number;
50
+ phase: 'pending' | 'active';
51
+ }
52
+
53
+ interface IKeyboardMoveFocus {
54
+ sessionId: string;
55
+ groupId: string | null;
56
+ }
57
+
58
+ type TSectionRenderEntry =
59
+ | { type: 'session'; key: string; session: IHarnessSessionMeta; logicalIndex: number }
60
+ | { type: 'placeholder'; key: string };
61
+
34
62
  /**
35
63
  * Searchable conversation list, embeddable in a sidebar or a DeesModal.
36
64
  * Emits `harness-session-select` on click, `harness-session-open` on
@@ -43,7 +71,8 @@ interface IDropSlot {
43
71
  * `harness-group-create-request`, and right-clicking a group header emits
44
72
  * `harness-group-context` — the host persists changes and passes fresh
45
73
  * `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.
74
+ * section with pinned entries first, then explicit order, then new sessions by
75
+ * recency. While a search query is active the list is flat.
47
76
  */
48
77
  @customElement('dees-harness-session-list')
49
78
  export class DeesHarnessSessionList extends DeesElement {
@@ -68,6 +97,10 @@ export class DeesHarnessSessionList extends DeesElement {
68
97
  @property({ type: Boolean })
69
98
  accessor enableGrouping: boolean = false;
70
99
 
100
+ /** Explicit order for sortable sessions outside user-defined groups. */
101
+ @property({ attribute: false })
102
+ accessor ungroupedSessionIds: string[] = [];
103
+
71
104
  @state()
72
105
  accessor search: string = '';
73
106
 
@@ -77,9 +110,37 @@ export class DeesHarnessSessionList extends DeesElement {
77
110
  @state()
78
111
  accessor dropSlot: IDropSlot | undefined = undefined;
79
112
 
113
+ @state()
114
+ accessor draggedItemHeight = 0;
115
+
116
+ @state()
117
+ accessor expandedSessionIds: ReadonlySet<string> = new Set<string>();
118
+
80
119
  @state()
81
120
  accessor collapsedGroupIds: ReadonlySet<string> = new Set<string>();
82
121
 
122
+ @state()
123
+ private accessor topScrollShadowVisible = false;
124
+
125
+ @state()
126
+ private accessor bottomScrollShadowVisible = false;
127
+
128
+ @state()
129
+ private accessor moveAnnouncement = '';
130
+
131
+ private reflowGeneration = 0;
132
+ private readonly reflowAnimations = new Set<Animation>();
133
+ private pointerDragGesture: IPointerDragGesture | undefined;
134
+ private keyboardMoveFocus: IKeyboardMoveFocus | undefined;
135
+ private scrollResizeObserver: ResizeObserver | undefined;
136
+ private observedScrollContainer: HTMLElement | undefined;
137
+ private observedScrollContent: HTMLElement | undefined;
138
+ private dragAutoScrollFrame: number | undefined;
139
+ private dragAutoScrollClientX = 0;
140
+ private dragAutoScrollClientY = 0;
141
+ private dragAutoScrollVelocity = 0;
142
+ private dragAutoScrollLastFrame = 0;
143
+
83
144
  public static styles = [
84
145
  themeDefaultStyles,
85
146
  cssManager.defaultStyles,
@@ -89,26 +150,48 @@ export class DeesHarnessSessionList extends DeesElement {
89
150
  flex-direction: column;
90
151
  min-height: 0;
91
152
  font-family: var(--dees-font-family);
153
+ --session-normal: var(--dees-color-text-muted);
154
+ --session-working: var(--dees-color-accent-primary);
155
+ --session-finished: var(--dees-color-accent-success);
156
+ --session-attention: #bf5af2;
157
+ --session-error: var(--dees-color-accent-error);
158
+ }
159
+
160
+ :host([gobright]) {
161
+ --session-attention: #af52de;
92
162
  }
93
163
 
94
164
  .searchRow {
165
+ position: relative;
95
166
  flex: 0 0 auto;
96
- padding: var(--dees-spacing-sm);
167
+ padding: var(--dees-spacing-sm) var(--dees-spacing-md) var(--dees-spacing-md);
168
+ }
169
+
170
+ .searchIcon {
171
+ position: absolute;
172
+ top: 50%;
173
+ left: calc(var(--dees-spacing-md) + 10px);
174
+ color: var(--dees-color-text-muted);
175
+ transform: translateY(-62%);
176
+ pointer-events: none;
97
177
  }
98
178
 
99
179
  .searchRow input {
100
180
  width: 100%;
101
- height: var(--dees-control-height-sm);
102
- padding: 0 var(--dees-spacing-md);
181
+ height: var(--dees-control-height-lg);
182
+ padding: 0 var(--dees-spacing-md) 0 34px;
103
183
  border: 1px solid var(--dees-color-border-default);
104
- border-radius: var(--dees-radius-md);
184
+ border-radius: var(--dees-radius-lg);
105
185
  corner-shape: var(--dees-corner-shape);
106
- background: var(--dees-color-bg-primary);
186
+ background: color-mix(in srgb, var(--dees-color-bg-primary) 78%, transparent);
107
187
  color: var(--dees-color-text-primary);
108
188
  font-family: inherit;
109
- font-size: var(--dees-font-control-size, 13px);
189
+ font-size: var(--dees-font-control-size-sm, 12px);
110
190
  outline: none;
111
191
  box-sizing: border-box;
192
+ box-shadow: var(--dees-shadow-xs);
193
+ transition: border-color var(--dees-transition-fast) var(--dees-ease-standard),
194
+ box-shadow var(--dees-transition-fast) var(--dees-ease-standard);
112
195
  }
113
196
 
114
197
  .searchRow input:focus {
@@ -117,51 +200,253 @@ export class DeesHarnessSessionList extends DeesElement {
117
200
  }
118
201
 
119
202
  .list {
120
- flex: 1;
121
- min-height: 0;
203
+ width: 100%;
204
+ height: 100%;
122
205
  overflow-y: auto;
123
206
  scrollbar-width: thin;
124
207
  scrollbar-color: var(--dees-color-scrollbar-thumb) transparent;
125
- padding: 0 var(--dees-spacing-xs) var(--dees-spacing-xs);
208
+ padding: 0 var(--dees-spacing-md) var(--dees-spacing-md);
209
+ box-sizing: border-box;
210
+ }
211
+
212
+ .listFrame {
213
+ position: relative;
214
+ flex: 1;
215
+ min-height: 0;
216
+ }
217
+
218
+ .scrollShadow {
219
+ position: absolute;
220
+ z-index: 1;
221
+ left: 0;
222
+ right: 0;
223
+ height: 16px;
224
+ opacity: 0;
225
+ pointer-events: none;
226
+ transition: opacity var(--dees-transition-fast) var(--dees-ease-standard);
227
+ }
228
+
229
+ .scrollShadow.visible {
230
+ opacity: 1;
231
+ }
232
+
233
+ .scrollShadow.top {
234
+ top: 0;
235
+ background: linear-gradient(
236
+ to bottom,
237
+ color-mix(in srgb, var(--dees-material-thin-bg-opaque) 74%, transparent),
238
+ transparent
239
+ );
240
+ box-shadow: var(--dees-shadow-sm);
241
+ }
242
+
243
+ .scrollShadow.bottom {
244
+ bottom: 0;
245
+ background: linear-gradient(
246
+ to top,
247
+ color-mix(in srgb, var(--dees-material-thin-bg-opaque) 74%, transparent),
248
+ transparent
249
+ );
250
+ box-shadow: var(--dees-shadow-up-sm);
251
+ }
252
+
253
+ .section {
254
+ position: relative;
126
255
  display: grid;
127
- gap: 2px;
256
+ gap: var(--dees-spacing-sm);
128
257
  align-content: start;
129
258
  }
130
259
 
131
260
  .item {
261
+ --card-accent: var(--session-normal);
262
+ position: relative;
132
263
  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);
264
+ min-width: 0;
265
+ overflow: hidden;
266
+ border: 1px solid color-mix(in srgb, var(--card-accent) 24%, var(--dees-color-border-subtle));
267
+ border-radius: var(--dees-radius-xl);
137
268
  corner-shape: var(--dees-corner-shape);
138
- background: transparent;
269
+ background: color-mix(in srgb, var(--dees-color-bg-primary) 90%, transparent);
139
270
  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);
271
+ box-shadow:
272
+ var(--dees-shadow-sm),
273
+ inset 3px 0 0 var(--card-accent);
274
+ box-sizing: border-box;
275
+ transition:
276
+ border-color var(--dees-transition-fast) var(--dees-ease-standard),
277
+ background var(--dees-transition-fast) var(--dees-ease-standard),
278
+ box-shadow var(--dees-transition-fast) var(--dees-ease-standard);
144
279
  }
145
280
 
146
281
  .item:hover {
147
- background: var(--dees-color-hover);
282
+ border-color: color-mix(in srgb, var(--card-accent) 42%, var(--dees-color-border-default));
283
+ background: color-mix(in srgb, var(--dees-color-bg-primary) 90%, var(--card-accent) 10%);
284
+ box-shadow:
285
+ var(--dees-shadow-md),
286
+ inset 3px 0 0 var(--card-accent);
148
287
  }
149
288
 
150
289
  .item.selected {
151
- background: var(--dees-color-active);
152
- box-shadow: inset 2px 0 0 var(--dees-color-accent-primary);
290
+ border-color: var(--dees-color-border-default);
291
+ border-left-color: var(--dees-color-accent-primary);
292
+ border-left-width: 3px;
293
+ background: color-mix(in srgb, var(--dees-color-bg-primary) 90%, transparent);
294
+ box-shadow: var(--dees-shadow-md);
295
+ }
296
+
297
+ .item.selected:hover {
298
+ border-color: var(--dees-color-border-default);
299
+ border-left-color: var(--dees-color-accent-primary);
300
+ background: color-mix(in srgb, var(--dees-color-bg-primary) 90%, transparent);
301
+ box-shadow: var(--dees-shadow-md);
302
+ }
303
+
304
+ .item.selected .titleText {
305
+ font-weight: 700;
153
306
  }
154
307
 
155
308
  .item.dragging {
156
- opacity: 0.4;
309
+ position: fixed;
310
+ inset: 0 auto auto 0;
311
+ width: var(--drag-preview-width);
312
+ height: var(--drag-preview-height);
313
+ max-width: none;
314
+ margin: 0;
315
+ padding: 0;
316
+ opacity: 0.96;
317
+ pointer-events: none;
318
+ transform: translate3d(var(--drag-preview-x), var(--drag-preview-y), 0);
319
+ box-shadow: var(--dees-shadow-xl);
157
320
  }
158
321
 
159
- .itemTitle {
322
+ .item.state-working {
323
+ --card-accent: var(--session-working);
324
+ }
325
+
326
+ @keyframes workingDotPulse {
327
+ 0%, 100% {
328
+ transform: scale(0.92);
329
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--session-working) 14%, transparent);
330
+ }
331
+ 50% {
332
+ transform: scale(1.08);
333
+ box-shadow: 0 0 0 6px color-mix(in srgb, var(--session-working) 3%, transparent);
334
+ }
335
+ }
336
+
337
+ .item.state-working .itemHeader .stateDot {
338
+ animation: workingDotPulse 2.4s ease-in-out infinite;
339
+ }
340
+
341
+ .item.state-finished {
342
+ --card-accent: var(--session-finished);
343
+ }
344
+
345
+ .item.state-attention {
346
+ --card-accent: var(--session-attention);
347
+ }
348
+
349
+ .item.state-error {
350
+ --card-accent: var(--session-error);
351
+ }
352
+
353
+ @keyframes attentionPulse {
354
+ 0%, 100% {
355
+ background: color-mix(in srgb, var(--dees-color-bg-primary) 94%, var(--session-attention) 6%);
356
+ }
357
+ 50% {
358
+ background: color-mix(in srgb, var(--dees-color-bg-primary) 84%, var(--session-attention) 16%);
359
+ }
360
+ }
361
+
362
+ .item.state-attention:not(.selected) {
363
+ animation: attentionPulse 1.15s ease-in-out infinite;
364
+ }
365
+
366
+ .item.state-attention:not(.selected):hover {
367
+ animation: none;
368
+ background: color-mix(in srgb, var(--dees-color-bg-primary) 82%, var(--session-attention) 18%);
369
+ }
370
+
371
+ .itemHeader {
372
+ display: grid;
373
+ grid-template-columns: minmax(0, 1fr) 30px;
374
+ min-height: 46px;
375
+ }
376
+
377
+ .itemHeader.draggable {
378
+ grid-template-columns: 24px minmax(0, 1fr) 30px;
379
+ }
380
+
381
+ .dragHandle {
382
+ appearance: none;
383
+ display: grid;
384
+ place-items: center;
385
+ padding: 0;
386
+ border: 0;
387
+ background: transparent;
388
+ color: var(--dees-color-text-muted);
389
+ cursor: grab;
390
+ touch-action: none;
391
+ }
392
+
393
+ .dragHandle:active {
394
+ cursor: grabbing;
395
+ }
396
+
397
+ .dragHandle:focus-visible {
398
+ outline: 2px solid var(--dees-color-accent-primary);
399
+ outline-offset: -3px;
400
+ border-radius: 8px;
401
+ }
402
+
403
+ .itemPrimary,
404
+ .expandToggle {
405
+ appearance: none;
406
+ border: 0;
407
+ background: transparent;
408
+ color: inherit;
409
+ font-family: inherit;
410
+ }
411
+
412
+ .itemPrimary {
160
413
  display: flex;
161
- align-items: baseline;
414
+ min-width: 0;
415
+ align-items: center;
162
416
  gap: var(--dees-spacing-sm);
417
+ padding: var(--dees-spacing-sm) var(--dees-spacing-xs) var(--dees-spacing-sm) var(--dees-spacing-md);
418
+ text-align: left;
419
+ cursor: pointer;
420
+ }
421
+
422
+ .itemPrimary:focus-visible,
423
+ .expandToggle:focus-visible {
424
+ outline: 2px solid var(--dees-color-accent-primary);
425
+ outline-offset: -3px;
426
+ border-radius: 9px;
427
+ }
428
+
429
+ .stateDot {
430
+ width: 8px;
431
+ height: 8px;
432
+ flex: 0 0 auto;
433
+ border-radius: 50%;
434
+ background: var(--card-accent);
435
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--card-accent) 15%, transparent);
436
+ }
437
+
438
+ .itemCopy {
439
+ min-width: 0;
440
+ flex: 1;
441
+ }
442
+
443
+ .itemTitle {
444
+ display: flex;
445
+ min-width: 0;
446
+ align-items: center;
447
+ gap: 6px;
163
448
  font-size: var(--dees-font-control-size-sm, 12px);
164
- font-weight: 500;
449
+ font-weight: 590;
165
450
  }
166
451
 
167
452
  .itemTitle .titleText {
@@ -173,24 +458,99 @@ export class DeesHarnessSessionList extends DeesElement {
173
458
 
174
459
  .itemTitle .itemIcon {
175
460
  flex: 0 0 auto;
176
- align-self: center;
177
461
  color: var(--dees-color-text-secondary);
178
462
  }
179
463
 
180
464
  .itemTime {
181
- margin-left: auto;
182
- flex: 0 0 auto;
465
+ margin-top: 2px;
183
466
  color: var(--dees-color-text-muted);
184
467
  font-size: var(--dees-font-caption2-size, 10px);
185
468
  }
186
469
 
470
+ .expandToggle {
471
+ display: grid;
472
+ place-items: center;
473
+ margin: 7px 7px 7px 0;
474
+ border-radius: 9px;
475
+ color: var(--dees-color-text-muted);
476
+ cursor: pointer;
477
+ }
478
+
479
+ .expandToggle:hover {
480
+ background: color-mix(in srgb, var(--card-accent) 11%, transparent);
481
+ color: var(--dees-color-text-primary);
482
+ }
483
+
484
+ .expandToggle dees-icon {
485
+ transition: transform var(--dees-transition-fast) var(--dees-ease-standard);
486
+ }
487
+
488
+ .item.expanded .expandToggle dees-icon {
489
+ transform: rotate(180deg);
490
+ }
491
+
492
+ .itemDetails {
493
+ display: grid;
494
+ gap: 6px;
495
+ margin: 0 10px 10px 28px;
496
+ padding: 9px 10px;
497
+ border-top: 1px solid color-mix(in srgb, var(--card-accent) 20%, var(--dees-color-border-subtle));
498
+ color: var(--dees-color-text-secondary);
499
+ }
500
+
501
+ .stateLine {
502
+ display: flex;
503
+ align-items: center;
504
+ gap: 6px;
505
+ color: var(--card-accent);
506
+ font-size: var(--dees-font-caption1-size, 11px);
507
+ font-weight: 650;
508
+ }
509
+
187
510
  .itemPreview {
188
- margin-top: 1px;
189
511
  color: var(--dees-color-text-secondary);
190
512
  font-size: var(--dees-font-caption1-size, 11px);
191
513
  overflow: hidden;
192
- text-overflow: ellipsis;
514
+ display: -webkit-box;
515
+ -webkit-line-clamp: 2;
516
+ -webkit-box-orient: vertical;
517
+ }
518
+
519
+ .itemMeta {
520
+ color: var(--dees-color-text-muted);
521
+ font-size: var(--dees-font-caption2-size, 10px);
522
+ }
523
+
524
+ .visuallyHidden {
525
+ position: absolute;
526
+ width: 1px;
527
+ height: 1px;
528
+ padding: 0;
529
+ margin: -1px;
530
+ overflow: hidden;
531
+ clip: rect(0, 0, 0, 0);
193
532
  white-space: nowrap;
533
+ border: 0;
534
+ }
535
+
536
+ .dropPlaceholder,
537
+ .emptyDropZone {
538
+ display: grid;
539
+ place-items: center;
540
+ min-height: 46px;
541
+ border: 1px dashed color-mix(in srgb, var(--dees-color-accent-primary) 68%, transparent);
542
+ border-radius: var(--dees-radius-xl);
543
+ background: color-mix(in srgb, var(--dees-color-accent-primary) 8%, transparent);
544
+ color: var(--dees-color-accent-primary);
545
+ box-sizing: border-box;
546
+ }
547
+
548
+ .dropPlaceholder::after,
549
+ .emptyDropZone::after {
550
+ content: 'Drop conversation here';
551
+ font-size: var(--dees-font-caption2-size, 10px);
552
+ font-weight: 650;
553
+ letter-spacing: 0.02em;
194
554
  }
195
555
 
196
556
  .emptyNote {
@@ -205,8 +565,8 @@ export class DeesHarnessSessionList extends DeesElement {
205
565
  align-items: center;
206
566
  gap: var(--dees-spacing-xs);
207
567
  width: 100%;
208
- margin-top: var(--dees-spacing-xs);
209
- padding: 2px var(--dees-spacing-sm);
568
+ margin: var(--dees-spacing-md) 0 7px;
569
+ padding: var(--dees-spacing-xs) 6px;
210
570
  border: 0;
211
571
  border-radius: var(--dees-radius-md);
212
572
  background: transparent;
@@ -246,15 +606,12 @@ export class DeesHarnessSessionList extends DeesElement {
246
606
  }
247
607
 
248
608
  .dropLine {
249
- height: 2px;
250
- margin: 0 var(--dees-spacing-sm);
251
- border-radius: 1px;
252
- background: var(--dees-color-accent-primary);
609
+ display: none;
253
610
  }
254
611
 
255
612
  .newGroupRow {
256
613
  flex: 0 0 auto;
257
- padding: var(--dees-spacing-xs) var(--dees-spacing-sm) var(--dees-spacing-sm);
614
+ padding: 0 var(--dees-spacing-md) var(--dees-spacing-md);
258
615
  }
259
616
 
260
617
  .newGroupButton {
@@ -262,10 +619,12 @@ export class DeesHarnessSessionList extends DeesElement {
262
619
  align-items: center;
263
620
  gap: var(--dees-spacing-xs);
264
621
  width: 100%;
622
+ min-height: 34px;
623
+ justify-content: center;
265
624
  padding: var(--dees-spacing-xs) var(--dees-spacing-sm);
266
625
  border: 1px dashed var(--dees-color-border-default);
267
- border-radius: var(--dees-radius-md);
268
- background: transparent;
626
+ border-radius: 11px;
627
+ background: color-mix(in srgb, var(--dees-color-bg-primary) 55%, transparent);
269
628
  color: var(--dees-color-text-secondary);
270
629
  cursor: pointer;
271
630
  font-family: inherit;
@@ -276,6 +635,17 @@ export class DeesHarnessSessionList extends DeesElement {
276
635
  border-color: var(--dees-color-accent-primary);
277
636
  color: var(--dees-color-text-primary);
278
637
  }
638
+
639
+ @media (prefers-reduced-motion: reduce) {
640
+ .item,
641
+ .item.state-attention,
642
+ .item.state-working .itemHeader .stateDot,
643
+ .scrollShadow,
644
+ .expandToggle dees-icon {
645
+ animation: none;
646
+ transition: none;
647
+ }
648
+ }
279
649
  `,
280
650
  ];
281
651
 
@@ -291,15 +661,26 @@ export class DeesHarnessSessionList extends DeesElement {
291
661
  return ids;
292
662
  }
293
663
 
294
- /** Ungrouped sessions, most recent first. */
664
+ /** Pinned entries first, followed by explicitly ordered sortable sessions. */
295
665
  private get ungroupedSessions(): IHarnessSessionMeta[] {
296
666
  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
- );
667
+ const available = this.sessions.filter((session) => !grouped.has(session.id));
668
+ const pinned = available.filter((session) => session.draggable === false);
669
+ const sortableById = new Map(
670
+ available.filter((session) => session.draggable !== false).map((session) => [session.id, session]),
671
+ );
672
+ const ordered: IHarnessSessionMeta[] = [];
673
+ for (const sessionId of this.ungroupedSessionIds) {
674
+ const session = sortableById.get(sessionId);
675
+ if (!session) continue;
676
+ ordered.push(session);
677
+ sortableById.delete(sessionId);
678
+ }
679
+ const newSessions = [...sortableById.values()].sort(
680
+ (left, right) =>
681
+ (right.updatedAt ?? right.createdAt ?? 0) - (left.updatedAt ?? left.createdAt ?? 0),
682
+ );
683
+ return [...pinned, ...ordered, ...newSessions];
303
684
  }
304
685
 
305
686
  private get filteredSessions(): IHarnessSessionMeta[] {
@@ -317,10 +698,63 @@ export class DeesHarnessSessionList extends DeesElement {
317
698
  this.shadowRoot?.querySelector<HTMLInputElement>('.searchRow input')?.focus();
318
699
  }
319
700
 
701
+ public updated(changedProperties: Map<PropertyKey, unknown>): void {
702
+ super.updated(changedProperties);
703
+ this.observeScrollGeometry();
704
+ this.syncScrollShadowState();
705
+ this.restoreKeyboardMoveFocus();
706
+ if (
707
+ this.pointerDragGesture
708
+ && (!this.pointerDragGesture.source.isConnected || !this.pointerDragGesture.handle.isConnected)
709
+ ) {
710
+ this.finishPointerDrag(false);
711
+ }
712
+ if (!changedProperties.has('sessions')) return;
713
+ const previousSessions = changedProperties.get('sessions') as IHarnessSessionMeta[] | undefined;
714
+ const previousById = new Map((previousSessions ?? []).map((session) => [session.id, session]));
715
+ const knownIds = new Set(this.sessions.map((session) => session.id));
716
+ if (this.pointerDragGesture && !knownIds.has(this.pointerDragGesture.sessionId)) {
717
+ this.finishPointerDrag(false);
718
+ }
719
+ const nextExpanded = new Set(
720
+ [...this.expandedSessionIds].filter((sessionId) => knownIds.has(sessionId)),
721
+ );
722
+ for (const session of this.sessions) {
723
+ const previous = previousById.get(session.id);
724
+ if (previous && previous.working !== true && session.working === true) {
725
+ nextExpanded.add(session.id);
726
+ }
727
+ }
728
+ if (
729
+ nextExpanded.size !== this.expandedSessionIds.size
730
+ || [...nextExpanded].some((sessionId) => !this.expandedSessionIds.has(sessionId))
731
+ ) {
732
+ this.expandedSessionIds = nextExpanded;
733
+ }
734
+ }
735
+
736
+ public async connectedCallback(): Promise<void> {
737
+ await super.connectedCallback();
738
+ await this.updateComplete;
739
+ if (!this.isConnected) return;
740
+ this.observeScrollGeometry();
741
+ this.syncScrollShadowState();
742
+ }
743
+
744
+ public async disconnectedCallback(): Promise<void> {
745
+ this.finishPointerDrag(false);
746
+ this.scrollResizeObserver?.disconnect();
747
+ this.scrollResizeObserver = undefined;
748
+ this.observedScrollContainer = undefined;
749
+ this.observedScrollContent = undefined;
750
+ await super.disconnectedCallback();
751
+ }
752
+
320
753
  public render(): TemplateResult {
321
754
  const groupingActive = this.enableGrouping && !this.search.trim();
322
755
  return html`
323
756
  <div class="searchRow">
757
+ <dees-icon class="searchIcon" icon="lucide:Search" iconSize="13"></dees-icon>
324
758
  <input
325
759
  placeholder="Search conversations"
326
760
  .value=${this.search}
@@ -329,13 +763,23 @@ export class DeesHarnessSessionList extends DeesElement {
329
763
  }}
330
764
  />
331
765
  </div>
332
- <div class="list">
333
- ${this.loading
334
- ? html`<div class="emptyNote">Loading conversations…</div>`
335
- : groupingActive
336
- ? this.renderGroupedList()
337
- : this.renderFlatList()}
766
+ <div class="listFrame">
767
+ <div class="scrollShadow top ${this.topScrollShadowVisible ? 'visible' : ''}"></div>
768
+ <div
769
+ class="list"
770
+ @scroll=${this.syncScrollShadowState}
771
+ >
772
+ <div class="listContent">
773
+ ${this.loading
774
+ ? html`<div class="emptyNote">Loading conversations…</div>`
775
+ : groupingActive
776
+ ? this.renderGroupedList()
777
+ : this.renderFlatList()}
778
+ </div>
779
+ </div>
780
+ <div class="scrollShadow bottom ${this.bottomScrollShadowVisible ? 'visible' : ''}"></div>
338
781
  </div>
782
+ <div class="visuallyHidden" aria-live="polite">${this.moveAnnouncement}</div>
339
783
  ${groupingActive
340
784
  ? html`
341
785
  <div class="newGroupRow">
@@ -367,7 +811,15 @@ export class DeesHarnessSessionList extends DeesElement {
367
811
  ${this.sessions.length ? 'No conversations match this search.' : this.emptyText}
368
812
  </div>`;
369
813
  }
370
- return html`${sessions.map((session) => this.renderItem(session, null, -1))}`;
814
+ return html`
815
+ <div class="section">
816
+ ${repeat(
817
+ sessions,
818
+ (session) => session.id,
819
+ (session) => this.renderItem(session, null, -1),
820
+ )}
821
+ </div>
822
+ `;
371
823
  }
372
824
 
373
825
  private renderGroupedList(): TemplateResult {
@@ -383,10 +835,15 @@ export class DeesHarnessSessionList extends DeesElement {
383
835
  .filter((session): session is IHarnessSessionMeta => session !== undefined);
384
836
  const collapsed = this.collapsedGroupIds.has(group.id);
385
837
  const isHeaderDrop =
386
- this.dropSlot?.groupId === group.id && this.dropSlot.index === members.length && collapsed;
838
+ this.dropSlot?.groupId === group.id
839
+ && this.dropSlot.index === this.sortableSessions(members).length
840
+ && collapsed;
387
841
  return html`
388
842
  <button
389
843
  class="groupHeader ${collapsed ? 'collapsed' : ''} ${isHeaderDrop ? 'dropTarget' : ''}"
844
+ data-drop-group-id=${group.id}
845
+ data-drop-index=${this.sortableSessions(members).length}
846
+ data-drop-kind="slot"
390
847
  type="button"
391
848
  @click=${() => this.toggleGroupCollapsed(group.id)}
392
849
  @contextmenu=${(event: MouseEvent) => {
@@ -401,8 +858,6 @@ export class DeesHarnessSessionList extends DeesElement {
401
858
  new CustomEvent('harness-group-context', { detail, bubbles: true, composed: true }),
402
859
  );
403
860
  }}
404
- @dragover=${(event: DragEvent) => this.handleDragOverSlot(event, group.id, members.length)}
405
- @drop=${(event: DragEvent) => this.handleDrop(event, group.id, members.length)}
406
861
  >
407
862
  <dees-icon class="chevron" icon="lucide:ChevronDown" iconSize="12"></dees-icon>
408
863
  <span>${group.name}</span>
@@ -413,14 +868,15 @@ export class DeesHarnessSessionList extends DeesElement {
413
868
  : this.renderSection(members, group.id)}
414
869
  `;
415
870
  })}
416
- ${this.groups.length && ungrouped.length
871
+ ${this.groups.length && (ungrouped.length || this.draggingSessionId)
417
872
  ? html`
418
873
  <button
419
874
  class="groupHeader"
875
+ data-drop-group-id=""
876
+ data-drop-index="0"
877
+ data-drop-kind="slot"
420
878
  type="button"
421
879
  tabindex="-1"
422
- @dragover=${(event: DragEvent) => this.handleDragOverSlot(event, null, 0)}
423
- @drop=${(event: DragEvent) => this.handleDrop(event, null, 0)}
424
880
  >
425
881
  <span>Ungrouped</span>
426
882
  <span class="groupCount">${ungrouped.length}</span>
@@ -436,18 +892,64 @@ export class DeesHarnessSessionList extends DeesElement {
436
892
  groupId: string | null,
437
893
  ): TemplateResult {
438
894
  const slot = this.dropSlot;
895
+ const entries: TSectionRenderEntry[] = [];
896
+ let logicalIndex = 0;
897
+ for (const session of sessions) {
898
+ const isDragging = session.id === this.draggingSessionId;
899
+ const sortable = session.draggable !== false;
900
+ if (
901
+ slot
902
+ && slot.groupId === groupId
903
+ && slot.index === logicalIndex
904
+ && !isDragging
905
+ && sortable
906
+ ) {
907
+ entries.push({ type: 'placeholder', key: `placeholder:${groupId ?? 'ungrouped'}` });
908
+ }
909
+ entries.push({
910
+ type: 'session',
911
+ key: `session:${session.id}`,
912
+ session,
913
+ logicalIndex: sortable ? logicalIndex : -1,
914
+ });
915
+ if (sortable && !isDragging) logicalIndex += 1;
916
+ }
917
+ if (slot && slot.groupId === groupId && slot.index === logicalIndex) {
918
+ entries.push({ type: 'placeholder', key: `placeholder:${groupId ?? 'ungrouped'}` });
919
+ }
920
+ const hasPlaceholder = entries.some((entry) => entry.type === 'placeholder');
439
921
  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
- : ''}
922
+ <div class="section" data-group-id=${groupId ?? ''}>
923
+ ${repeat(
924
+ entries,
925
+ (entry) => entry.key,
926
+ (entry) => entry.type === 'placeholder'
927
+ ? this.renderDropPlaceholder(groupId, slot?.index ?? 0)
928
+ : this.renderItem(entry.session, groupId, entry.logicalIndex),
929
+ )}
930
+ ${this.draggingSessionId && !hasPlaceholder && this.sortableSessions(sessions).length === 0
931
+ ? html`
932
+ <div
933
+ class="emptyDropZone"
934
+ data-drop-group-id=${groupId ?? ''}
935
+ data-drop-index="0"
936
+ data-drop-kind="slot"
937
+ ></div>
938
+ `
939
+ : ''}
940
+ </div>
941
+ `;
942
+ }
943
+
944
+ private renderDropPlaceholder(groupId: string | null, index: number): TemplateResult {
945
+ return html`
946
+ <div
947
+ class="dropPlaceholder"
948
+ data-drop-group-id=${groupId ?? ''}
949
+ data-drop-index=${index}
950
+ data-drop-kind="slot"
951
+ style=${`height: ${Math.max(46, this.draggedItemHeight)}px`}
952
+ ></div>
451
953
  `;
452
954
  }
453
955
 
@@ -456,20 +958,32 @@ export class DeesHarnessSessionList extends DeesElement {
456
958
  groupId: string | null,
457
959
  index: number,
458
960
  ): TemplateResult {
459
- const draggable = this.enableGrouping && index >= 0;
961
+ const draggable = (
962
+ this.enableGrouping
963
+ && session.draggable !== false
964
+ && index >= 0
965
+ );
966
+ const expanded = this.expandedSessionIds.has(session.id);
967
+ const state = session.state ?? 'normal';
968
+ const detailsId = `session-details-${session.id}`;
969
+ const dropIndex = index < 0 ? 0 : index;
460
970
  return html`
461
- <button
462
- class="item ${session.id === this.selectedSessionId ? 'selected' : ''} ${
971
+ <article
972
+ class="item state-${state} ${expanded ? 'expanded' : ''} ${
973
+ session.id === this.selectedSessionId ? 'selected' : ''
974
+ } ${
463
975
  this.draggingSessionId === session.id ? 'dragging' : ''
464
976
  }"
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);
977
+ data-session-id=${session.id}
978
+ data-drop-group-id=${groupId ?? ''}
979
+ data-drop-index=${dropIndex}
980
+ data-drop-kind=${index < 0 ? 'slot' : 'card'}
981
+ @dblclick=${(event: MouseEvent) => {
982
+ if (this.eventTargetsExpandToggle(event) || this.eventTargetsDragHandle(event)) return;
983
+ this.emit('harness-session-open', session);
471
984
  }}
472
985
  @contextmenu=${(event: MouseEvent) => {
986
+ if (this.eventTargetsExpandToggle(event)) return;
473
987
  event.preventDefault();
474
988
  const detail: IHarnessSessionContextDetail = {
475
989
  session,
@@ -481,44 +995,115 @@ export class DeesHarnessSessionList extends DeesElement {
481
995
  new CustomEvent('harness-session-context', { detail, bubbles: true, composed: true }),
482
996
  );
483
997
  }}
484
- @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;
493
- }}
494
- @dragover=${(event: DragEvent) => {
495
- if (!this.draggingSessionId || index < 0) return;
496
- event.preventDefault();
497
- const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
498
- const before = event.clientY < rect.top + rect.height / 2;
499
- this.setDropSlot(groupId, before ? index : index + 1);
500
- }}
501
- @drop=${(event: DragEvent) => {
502
- if (index < 0) return;
503
- const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
504
- const before = event.clientY < rect.top + rect.height / 2;
505
- this.handleDrop(event, groupId, before ? index : index + 1);
506
- }}
507
998
  >
508
- <span class="itemTitle">
509
- ${session.icon
510
- ? html`<dees-icon class="itemIcon" icon=${session.icon} iconSize="12"></dees-icon>`
999
+ <div class="itemHeader ${draggable ? 'draggable' : ''}">
1000
+ ${draggable
1001
+ ? html`
1002
+ <button
1003
+ class="dragHandle"
1004
+ type="button"
1005
+ title="Drag to reorder; use arrow, Home, or End keys for keyboard reordering"
1006
+ aria-label=${`Reorder ${session.title || session.id}`}
1007
+ aria-keyshortcuts="ArrowUp ArrowDown Home End"
1008
+ @pointerdown=${(event: PointerEvent) => {
1009
+ this.handlePointerDown(event, session, groupId, index);
1010
+ }}
1011
+ @pointermove=${this.handlePointerMove}
1012
+ @pointerup=${this.handlePointerUp}
1013
+ @pointercancel=${this.handlePointerCancel}
1014
+ @lostpointercapture=${this.handleLostPointerCapture}
1015
+ @keydown=${(event: KeyboardEvent) => {
1016
+ this.handleKeyboardMove(event, session, groupId);
1017
+ }}
1018
+ >
1019
+ <dees-icon icon="lucide:GripVertical" iconSize="14"></dees-icon>
1020
+ </button>
1021
+ `
511
1022
  : ''}
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>`
1023
+ <button
1024
+ class="itemPrimary"
1025
+ type="button"
1026
+ aria-current=${session.id === this.selectedSessionId ? 'true' : 'false'}
1027
+ @click=${() => this.emit('harness-session-select', session)}
1028
+ @keydown=${(event: KeyboardEvent) => {
1029
+ if (event.key !== 'Enter') return;
1030
+ event.preventDefault();
1031
+ this.emit('harness-session-open', session);
1032
+ }}
1033
+ >
1034
+ <span class="stateDot"></span>
1035
+ <span class="itemCopy">
1036
+ <span class="itemTitle">
1037
+ ${session.icon
1038
+ ? html`<dees-icon class="itemIcon" icon=${session.icon} iconSize="13"></dees-icon>`
1039
+ : ''}
1040
+ <span class="titleText">${session.title || session.id}</span>
1041
+ </span>
1042
+ <span class="itemTime">${harnessFormatRelativeTime(session.updatedAt ?? session.createdAt)}</span>
1043
+ <span class="visuallyHidden">Status: ${this.sessionStateLabel(state)}</span>
1044
+ </span>
1045
+ </button>
1046
+ <button
1047
+ class="expandToggle"
1048
+ type="button"
1049
+ aria-expanded=${expanded ? 'true' : 'false'}
1050
+ aria-controls=${detailsId}
1051
+ title=${expanded ? 'Collapse conversation card' : 'Expand conversation card'}
1052
+ @click=${(event: MouseEvent) => {
1053
+ event.stopPropagation();
1054
+ this.toggleSessionExpanded(session.id);
1055
+ }}
1056
+ >
1057
+ <dees-icon icon="lucide:ChevronDown" iconSize="15"></dees-icon>
1058
+ </button>
1059
+ </div>
1060
+ ${expanded
1061
+ ? html`
1062
+ <div class="itemDetails" id=${detailsId}>
1063
+ <div class="stateLine">
1064
+ <span class="stateDot"></span>
1065
+ <span>${this.sessionStateLabel(state)}</span>
1066
+ </div>
1067
+ ${session.preview ? html`<div class="itemPreview">${session.preview}</div>` : ''}
1068
+ <div class="itemMeta">
1069
+ ${session.messageCount ? `${session.messageCount} messages · ` : ''}${session.id}
1070
+ </div>
1071
+ </div>
1072
+ `
517
1073
  : ''}
518
- </button>
1074
+ </article>
519
1075
  `;
520
1076
  }
521
1077
 
1078
+ private eventTargetsExpandToggle(event: Event): boolean {
1079
+ return event.composedPath().some(
1080
+ (target) => target instanceof HTMLElement && target.classList.contains('expandToggle'),
1081
+ );
1082
+ }
1083
+
1084
+ private eventTargetsDragHandle(event: Event): boolean {
1085
+ return event.composedPath().some(
1086
+ (target) => target instanceof HTMLElement && target.classList.contains('dragHandle'),
1087
+ );
1088
+ }
1089
+
1090
+ private sessionStateLabel(state: NonNullable<IHarnessSessionMeta['state']>): string {
1091
+ switch (state) {
1092
+ case 'working': return 'Working';
1093
+ case 'finished': return 'Finished';
1094
+ case 'attention': return 'Needs attention';
1095
+ case 'error': return 'Error';
1096
+ default: return 'Idle';
1097
+ }
1098
+ }
1099
+
1100
+ private toggleSessionExpanded(sessionIdArg: string): void {
1101
+ const next = new Set(this.expandedSessionIds);
1102
+ if (next.has(sessionIdArg)) next.delete(sessionIdArg);
1103
+ else next.add(sessionIdArg);
1104
+ this.expandedSessionIds = next;
1105
+ }
1106
+
522
1107
  private toggleGroupCollapsed(groupIdArg: string): void {
523
1108
  const next = new Set(this.collapsedGroupIds);
524
1109
  if (next.has(groupIdArg)) {
@@ -529,35 +1114,518 @@ export class DeesHarnessSessionList extends DeesElement {
529
1114
  this.collapsedGroupIds = next;
530
1115
  }
531
1116
 
1117
+ private sortableSessions(
1118
+ sessionsArg: IHarnessSessionMeta[],
1119
+ excludeDraggingArg = true,
1120
+ ): IHarnessSessionMeta[] {
1121
+ return sessionsArg.filter((session) => (
1122
+ session.draggable !== false
1123
+ && (!excludeDraggingArg || session.id !== this.draggingSessionId)
1124
+ ));
1125
+ }
1126
+
1127
+ private sessionsForGroup(groupIdArg: string | null): IHarnessSessionMeta[] {
1128
+ if (groupIdArg === null) return this.ungroupedSessions;
1129
+ const group = this.groups.find((candidate) => candidate.id === groupIdArg);
1130
+ if (!group) return [];
1131
+ const byId = this.sessionsById;
1132
+ return group.sessionIds
1133
+ .map((sessionId) => byId.get(sessionId))
1134
+ .filter((session): session is IHarnessSessionMeta => session !== undefined);
1135
+ }
1136
+
1137
+ private handlePointerDown(
1138
+ eventArg: PointerEvent,
1139
+ sessionArg: IHarnessSessionMeta,
1140
+ groupIdArg: string | null,
1141
+ logicalIndexArg: number,
1142
+ ): void {
1143
+ if (
1144
+ this.pointerDragGesture
1145
+ || eventArg.button !== 0
1146
+ || !eventArg.isPrimary
1147
+ || logicalIndexArg < 0
1148
+ ) return;
1149
+ const handle = eventArg.currentTarget as HTMLElement;
1150
+ const source = handle.closest<HTMLElement>('.item');
1151
+ if (!source) return;
1152
+ const sourceRect = source.getBoundingClientRect();
1153
+ this.pointerDragGesture = {
1154
+ pointerId: eventArg.pointerId,
1155
+ handle,
1156
+ source,
1157
+ sessionId: sessionArg.id,
1158
+ sourceGroupId: groupIdArg,
1159
+ sourceLogicalIndex: logicalIndexArg,
1160
+ startClientX: eventArg.clientX,
1161
+ startClientY: eventArg.clientY,
1162
+ grabOffsetX: eventArg.clientX - sourceRect.left,
1163
+ grabOffsetY: eventArg.clientY - sourceRect.top,
1164
+ phase: 'pending',
1165
+ };
1166
+ window.addEventListener('keydown', this.handleDragKeyDown, true);
1167
+ window.addEventListener('blur', this.handleDragWindowBlur);
1168
+ try {
1169
+ handle.setPointerCapture(eventArg.pointerId);
1170
+ } catch {
1171
+ this.finishPointerDrag(false);
1172
+ }
1173
+ }
1174
+
1175
+ private readonly handlePointerMove = (eventArg: PointerEvent): void => {
1176
+ const gesture = this.pointerDragGesture;
1177
+ if (!gesture || eventArg.pointerId !== gesture.pointerId) return;
1178
+ if (!gesture.source.isConnected || !gesture.handle.isConnected) {
1179
+ this.finishPointerDrag(false);
1180
+ return;
1181
+ }
1182
+ if (
1183
+ gesture.phase === 'pending'
1184
+ && Math.hypot(
1185
+ eventArg.clientX - gesture.startClientX,
1186
+ eventArg.clientY - gesture.startClientY,
1187
+ ) < dragThresholdPixels
1188
+ ) return;
1189
+ if (gesture.phase === 'pending' && !this.startPointerDrag(eventArg, gesture)) return;
1190
+ eventArg.preventDefault();
1191
+ this.moveDragPreview(eventArg.clientX, eventArg.clientY, gesture);
1192
+ this.dragAutoScrollClientX = eventArg.clientX;
1193
+ this.dragAutoScrollClientY = eventArg.clientY;
1194
+ this.updateDragAutoScrollVelocity();
1195
+ this.updateDropSlotFromPointer();
1196
+ };
1197
+
1198
+ private readonly handlePointerUp = (eventArg: PointerEvent): void => {
1199
+ const gesture = this.pointerDragGesture;
1200
+ if (!gesture || eventArg.pointerId !== gesture.pointerId) return;
1201
+ if (gesture.phase === 'active') {
1202
+ this.dragAutoScrollClientX = eventArg.clientX;
1203
+ this.dragAutoScrollClientY = eventArg.clientY;
1204
+ this.moveDragPreview(eventArg.clientX, eventArg.clientY, gesture);
1205
+ this.updateDropSlotFromPointer();
1206
+ this.finishPointerDrag(true);
1207
+ return;
1208
+ }
1209
+ this.finishPointerDrag(false);
1210
+ };
1211
+
1212
+ private readonly handlePointerCancel = (eventArg: PointerEvent): void => {
1213
+ if (eventArg.pointerId === this.pointerDragGesture?.pointerId) this.finishPointerDrag(false);
1214
+ };
1215
+
1216
+ private readonly handleLostPointerCapture = (eventArg: PointerEvent): void => {
1217
+ if (eventArg.pointerId === this.pointerDragGesture?.pointerId) this.finishPointerDrag(false);
1218
+ };
1219
+
1220
+ private readonly handleDragKeyDown = (eventArg: KeyboardEvent): void => {
1221
+ if (eventArg.key !== 'Escape' || !this.pointerDragGesture) return;
1222
+ eventArg.preventDefault();
1223
+ eventArg.stopPropagation();
1224
+ this.finishPointerDrag(false);
1225
+ };
1226
+
1227
+ private readonly handleDragWindowBlur = (): void => {
1228
+ this.finishPointerDrag(false);
1229
+ };
1230
+
1231
+ private startPointerDrag(eventArg: PointerEvent, gestureArg: IPointerDragGesture): boolean {
1232
+ const sourceRect = gestureArg.source.getBoundingClientRect();
1233
+ gestureArg.source.classList.add('dragging');
1234
+ gestureArg.source.style.setProperty('--drag-preview-width', `${sourceRect.width}px`);
1235
+ gestureArg.source.style.setProperty('--drag-preview-height', `${sourceRect.height}px`);
1236
+ this.moveDragPreview(eventArg.clientX, eventArg.clientY, gestureArg);
1237
+ gestureArg.source.setAttribute('popover', 'manual');
1238
+ try {
1239
+ gestureArg.source.showPopover();
1240
+ } catch {
1241
+ this.finishPointerDrag(false);
1242
+ return false;
1243
+ }
1244
+ gestureArg.phase = 'active';
1245
+ this.reflowGeneration += 1;
1246
+ this.cancelReflowAnimations();
1247
+ this.draggedItemHeight = Math.ceil(sourceRect.height);
1248
+ this.draggingSessionId = gestureArg.sessionId;
1249
+ const remainingCount = this.sortableSessions(this.sessionsForGroup(gestureArg.sourceGroupId)).length;
1250
+ this.dropSlot = {
1251
+ groupId: gestureArg.sourceGroupId,
1252
+ index: Math.min(gestureArg.sourceLogicalIndex, remainingCount),
1253
+ };
1254
+ return true;
1255
+ }
1256
+
1257
+ private moveDragPreview(
1258
+ clientXArg: number,
1259
+ clientYArg: number,
1260
+ gestureArg: IPointerDragGesture,
1261
+ ): void {
1262
+ gestureArg.source.style.setProperty(
1263
+ '--drag-preview-x',
1264
+ `${clientXArg - gestureArg.grabOffsetX}px`,
1265
+ );
1266
+ gestureArg.source.style.setProperty(
1267
+ '--drag-preview-y',
1268
+ `${clientYArg - gestureArg.grabOffsetY}px`,
1269
+ );
1270
+ }
1271
+
532
1272
  private setDropSlot(groupIdArg: string | null, indexArg: number): void {
533
1273
  const current = this.dropSlot;
534
1274
  if (current && current.groupId === groupIdArg && current.index === indexArg) return;
1275
+ const previousRects = this.captureCardRects();
1276
+ const generation = ++this.reflowGeneration;
535
1277
  this.dropSlot = { groupId: groupIdArg, index: indexArg };
1278
+ void this.animateCardReflow(previousRects, generation);
536
1279
  }
537
1280
 
538
- private handleDragOverSlot(event: DragEvent, groupIdArg: string | null, indexArg: number): void {
539
- if (!this.draggingSessionId) return;
540
- event.preventDefault();
541
- this.setDropSlot(groupIdArg, indexArg);
1281
+ private clearDropSlot(): void {
1282
+ if (!this.dropSlot) return;
1283
+ const previousRects = this.captureCardRects();
1284
+ const generation = ++this.reflowGeneration;
1285
+ this.dropSlot = undefined;
1286
+ void this.animateCardReflow(previousRects, generation);
542
1287
  }
543
1288
 
544
- private handleDrop(event: DragEvent, groupIdArg: string | null, indexArg: number): void {
545
- event.preventDefault();
546
- event.stopPropagation();
547
- const sessionId = this.draggingSessionId || event.dataTransfer?.getData('text/plain') || '';
548
- this.draggingSessionId = '';
549
- this.dropSlot = undefined;
550
- if (!sessionId) return;
1289
+ private updateDragAutoScrollVelocity(): void {
1290
+ const container = this.scrollContainer;
1291
+ if (!container || !this.draggingSessionId) return;
1292
+ const rect = container.getBoundingClientRect();
1293
+ if (this.dragAutoScrollClientX < rect.left || this.dragAutoScrollClientX > rect.right) {
1294
+ this.stopDragAutoScroll();
1295
+ return;
1296
+ }
1297
+ const edgeSize = Math.min(64, Math.max(32, rect.height * 0.25));
1298
+ const topDepth = Math.max(
1299
+ 0,
1300
+ Math.min(1, (rect.top + edgeSize - this.dragAutoScrollClientY) / edgeSize),
1301
+ );
1302
+ const bottomDepth = Math.max(
1303
+ 0,
1304
+ Math.min(1, (this.dragAutoScrollClientY - (rect.bottom - edgeSize)) / edgeSize),
1305
+ );
1306
+ this.dragAutoScrollVelocity = bottomDepth > 0
1307
+ ? dragAutoScrollMaxPixelsPerSecond * bottomDepth * bottomDepth
1308
+ : topDepth > 0
1309
+ ? -dragAutoScrollMaxPixelsPerSecond * topDepth * topDepth
1310
+ : 0;
1311
+ if (this.dragAutoScrollVelocity === 0) {
1312
+ this.stopDragAutoScroll();
1313
+ return;
1314
+ }
1315
+ if (this.dragAutoScrollFrame === undefined) {
1316
+ this.dragAutoScrollLastFrame = performance.now();
1317
+ this.dragAutoScrollFrame = requestAnimationFrame(this.runDragAutoScroll);
1318
+ }
1319
+ }
1320
+
1321
+ private readonly runDragAutoScroll = (timestampArg: number): void => {
1322
+ this.dragAutoScrollFrame = undefined;
1323
+ const container = this.scrollContainer;
1324
+ if (!container || !this.draggingSessionId || this.dragAutoScrollVelocity === 0) return;
1325
+ const elapsedMs = Math.min(34, Math.max(0, timestampArg - this.dragAutoScrollLastFrame));
1326
+ this.dragAutoScrollLastFrame = timestampArg;
1327
+ const previousScrollTop = container.scrollTop;
1328
+ container.scrollTop += this.dragAutoScrollVelocity * elapsedMs / 1000;
1329
+ this.syncScrollShadowState();
1330
+ const atScrollBoundary = this.dragAutoScrollVelocity < 0
1331
+ ? container.scrollTop <= 0
1332
+ : container.scrollTop + container.clientHeight >= container.scrollHeight - 1;
1333
+ if (container.scrollTop === previousScrollTop && atScrollBoundary) {
1334
+ this.stopDragAutoScroll();
1335
+ return;
1336
+ }
1337
+ this.updateDropSlotFromPointer();
1338
+ this.dragAutoScrollFrame = requestAnimationFrame(this.runDragAutoScroll);
1339
+ };
1340
+
1341
+ private updateDropSlotFromPointer(): void {
1342
+ const pointerX = this.dragAutoScrollClientX;
1343
+ const pointerY = this.dragAutoScrollClientY;
1344
+ const placeholder = this.shadowRoot?.querySelector<HTMLElement>('.dropPlaceholder');
1345
+ if (placeholder) {
1346
+ const rect = placeholder.getBoundingClientRect();
1347
+ if (
1348
+ pointerX >= rect.left
1349
+ && pointerX <= rect.right
1350
+ && pointerY >= rect.top
1351
+ && pointerY <= rect.bottom
1352
+ ) {
1353
+ this.setDropSlot(
1354
+ placeholder.dataset.dropGroupId || null,
1355
+ Number(placeholder.dataset.dropIndex),
1356
+ );
1357
+ return;
1358
+ }
1359
+ }
1360
+ const selector = '[data-drop-group-id][data-drop-index][data-drop-kind]';
1361
+ let target: HTMLElement | undefined;
1362
+ const offsets = this.dragAutoScrollVelocity === 0
1363
+ ? [0, -8, 8, -16, 16, -32, 32]
1364
+ : [0, 8, 16, 32, 48, 64, 96].map(
1365
+ (value) => value * (this.dragAutoScrollVelocity < 0 ? 1 : -1),
1366
+ );
1367
+ for (const offset of offsets) {
1368
+ const hit = this.shadowRoot?.elementFromPoint(pointerX, pointerY + offset);
1369
+ const candidate = hit?.closest<HTMLElement>(selector);
1370
+ if (
1371
+ candidate
1372
+ && candidate.dataset.sessionId !== this.draggingSessionId
1373
+ && Number(candidate.dataset.dropIndex) >= 0
1374
+ ) {
1375
+ target = candidate;
1376
+ break;
1377
+ }
1378
+ }
1379
+ if (!target) {
1380
+ this.clearDropSlot();
1381
+ return;
1382
+ }
1383
+ const baseIndex = Number(target.dataset.dropIndex);
1384
+ const groupId = target.dataset.dropGroupId || null;
1385
+ const rect = target.getBoundingClientRect();
1386
+ const index = target.dataset.dropKind === 'card' && pointerY >= rect.top + rect.height / 2
1387
+ ? baseIndex + 1
1388
+ : baseIndex;
1389
+ this.setDropSlot(groupId, index);
1390
+ }
1391
+
1392
+ private get scrollContainer(): HTMLElement | undefined {
1393
+ return this.shadowRoot?.querySelector<HTMLElement>('.list') ?? undefined;
1394
+ }
1395
+
1396
+ private observeScrollGeometry(): void {
1397
+ if (!this.isConnected) return;
1398
+ const container = this.scrollContainer;
1399
+ const content = this.shadowRoot?.querySelector<HTMLElement>('.listContent') ?? undefined;
1400
+ if (!container || !content) return;
1401
+ if (
1402
+ this.scrollResizeObserver
1403
+ && this.observedScrollContainer === container
1404
+ && this.observedScrollContent === content
1405
+ ) return;
1406
+ this.scrollResizeObserver?.disconnect();
1407
+ this.scrollResizeObserver = new ResizeObserver(this.syncScrollShadowState);
1408
+ this.scrollResizeObserver.observe(container);
1409
+ this.scrollResizeObserver.observe(content);
1410
+ this.observedScrollContainer = container;
1411
+ this.observedScrollContent = content;
1412
+ }
1413
+
1414
+ private readonly syncScrollShadowState = (): void => {
1415
+ const container = this.scrollContainer;
1416
+ if (!container) return;
1417
+ const topVisible = container.scrollTop > 1;
1418
+ const bottomVisible = container.scrollTop + container.clientHeight < container.scrollHeight - 1;
1419
+ if (this.topScrollShadowVisible !== topVisible) this.topScrollShadowVisible = topVisible;
1420
+ if (this.bottomScrollShadowVisible !== bottomVisible) this.bottomScrollShadowVisible = bottomVisible;
1421
+ };
1422
+
1423
+ private stopDragAutoScroll(): void {
1424
+ if (this.dragAutoScrollFrame !== undefined) cancelAnimationFrame(this.dragAutoScrollFrame);
1425
+ this.dragAutoScrollFrame = undefined;
1426
+ this.dragAutoScrollVelocity = 0;
1427
+ this.dragAutoScrollLastFrame = 0;
1428
+ }
1429
+
1430
+ private createMoveDetail(
1431
+ sessionIdArg: string,
1432
+ groupIdArg: string | null,
1433
+ indexArg: number,
1434
+ ): IHarnessSessionMoveDetail {
1435
+ const targetSessions = this.sortableSessions(this.sessionsForGroup(groupIdArg));
1436
+ const beforeSessionId = targetSessions[Math.max(0, indexArg)]?.id;
1437
+ const sourceGroupId = this.groups.find((group) => group.sessionIds.includes(sessionIdArg))?.id ?? null;
1438
+ const sourceSessions = this.sortableSessions(this.sessionsForGroup(sourceGroupId), false);
1439
+ const sourceIndex = sourceSessions.findIndex((session) => session.id === sessionIdArg);
1440
+ const compatibilityIndex = (
1441
+ sourceGroupId === groupIdArg
1442
+ && sourceIndex >= 0
1443
+ && indexArg > sourceIndex
1444
+ ) ? indexArg + 1 : indexArg;
1445
+ return {
1446
+ sessionId: sessionIdArg,
1447
+ groupId: groupIdArg,
1448
+ index: Math.max(0, compatibilityIndex),
1449
+ ...(beforeSessionId ? { beforeSessionId } : {}),
1450
+ };
1451
+ }
1452
+
1453
+ private handleKeyboardMove(
1454
+ eventArg: KeyboardEvent,
1455
+ sessionArg: IHarnessSessionMeta,
1456
+ groupIdArg: string | null,
1457
+ ): void {
1458
+ if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(eventArg.key)) return;
1459
+ eventArg.preventDefault();
1460
+ eventArg.stopPropagation();
1461
+ const sessions = this.sortableSessions(this.sessionsForGroup(groupIdArg), false);
1462
+ const sourceIndex = sessions.findIndex((session) => session.id === sessionArg.id);
1463
+ if (sourceIndex < 0) return;
1464
+ const sectionIds: Array<string | null> = [...this.groups.map((group) => group.id), null];
1465
+ const sectionIndex = sectionIds.indexOf(groupIdArg);
1466
+ const adjacentGroupId = eventArg.key === 'ArrowUp' && sourceIndex === 0 && sectionIndex > 0
1467
+ ? sectionIds[sectionIndex - 1]
1468
+ : eventArg.key === 'ArrowDown'
1469
+ && sourceIndex === sessions.length - 1
1470
+ && sectionIndex >= 0
1471
+ && sectionIndex < sectionIds.length - 1
1472
+ ? sectionIds[sectionIndex + 1]
1473
+ : undefined;
1474
+ if (adjacentGroupId !== undefined) {
1475
+ const targetSessions = this.sortableSessions(this.sessionsForGroup(adjacentGroupId), false);
1476
+ const targetIndex = eventArg.key === 'ArrowUp' ? targetSessions.length : 0;
1477
+ const beforeSession = targetSessions[targetIndex];
1478
+ const detail: IHarnessSessionMoveDetail = {
1479
+ sessionId: sessionArg.id,
1480
+ groupId: adjacentGroupId,
1481
+ index: targetIndex,
1482
+ ...(beforeSession ? { beforeSessionId: beforeSession.id } : {}),
1483
+ };
1484
+ const groupName = adjacentGroupId === null
1485
+ ? 'Ungrouped'
1486
+ : this.groups.find((group) => group.id === adjacentGroupId)?.name ?? adjacentGroupId;
1487
+ this.moveAnnouncement = `Moved ${sessionArg.title || sessionArg.id} to ${groupName}.`;
1488
+ this.keyboardMoveFocus = { sessionId: sessionArg.id, groupId: adjacentGroupId };
1489
+ this.dispatchEvent(
1490
+ new CustomEvent('harness-session-move', { detail, bubbles: true, composed: true }),
1491
+ );
1492
+ return;
1493
+ }
1494
+ const remaining = sessions.filter((session) => session.id !== sessionArg.id);
1495
+ const targetIndex = eventArg.key === 'Home'
1496
+ ? 0
1497
+ : eventArg.key === 'End'
1498
+ ? remaining.length
1499
+ : eventArg.key === 'ArrowUp'
1500
+ ? Math.max(0, sourceIndex - 1)
1501
+ : Math.min(remaining.length, sourceIndex + 1);
1502
+ if (targetIndex === sourceIndex) return;
1503
+ const beforeSession = remaining[targetIndex];
1504
+ const compatibilityIndex = targetIndex > sourceIndex ? targetIndex + 1 : targetIndex;
551
1505
  const detail: IHarnessSessionMoveDetail = {
552
- sessionId,
1506
+ sessionId: sessionArg.id,
553
1507
  groupId: groupIdArg,
554
- index: Math.max(0, indexArg),
1508
+ index: compatibilityIndex,
1509
+ ...(beforeSession ? { beforeSessionId: beforeSession.id } : {}),
555
1510
  };
1511
+ this.moveAnnouncement = beforeSession
1512
+ ? `Moved ${sessionArg.title || sessionArg.id} before ${beforeSession.title || beforeSession.id}.`
1513
+ : `Moved ${sessionArg.title || sessionArg.id} to the end.`;
556
1514
  this.dispatchEvent(
557
1515
  new CustomEvent('harness-session-move', { detail, bubbles: true, composed: true }),
558
1516
  );
559
1517
  }
560
1518
 
1519
+ private captureCardRects(): Map<string, DOMRect> {
1520
+ const result = new Map<string, DOMRect>();
1521
+ for (const item of this.shadowRoot?.querySelectorAll<HTMLElement>('.item[data-session-id]') ?? []) {
1522
+ if (item.dataset.sessionId === this.draggingSessionId) continue;
1523
+ result.set(item.dataset.sessionId ?? '', item.getBoundingClientRect());
1524
+ }
1525
+ return result;
1526
+ }
1527
+
1528
+ private restoreKeyboardMoveFocus(): void {
1529
+ const pending = this.keyboardMoveFocus;
1530
+ if (!pending) return;
1531
+ const currentGroupId = this.groups.find(
1532
+ (group) => group.sessionIds.includes(pending.sessionId),
1533
+ )?.id ?? null;
1534
+ if (currentGroupId !== pending.groupId) return;
1535
+ const item = Array.from(
1536
+ this.shadowRoot?.querySelectorAll<HTMLElement>('.item[data-session-id]') ?? [],
1537
+ ).find((candidate) => candidate.dataset.sessionId === pending.sessionId);
1538
+ const handle = item?.querySelector<HTMLElement>('.dragHandle');
1539
+ if (handle) {
1540
+ handle.focus();
1541
+ this.keyboardMoveFocus = undefined;
1542
+ return;
1543
+ }
1544
+ const groupHeader = Array.from(
1545
+ this.shadowRoot?.querySelectorAll<HTMLElement>('.groupHeader[data-drop-group-id]') ?? [],
1546
+ ).find((candidate) => candidate.dataset.dropGroupId === (pending.groupId ?? ''));
1547
+ if (groupHeader) {
1548
+ groupHeader.focus();
1549
+ this.keyboardMoveFocus = undefined;
1550
+ }
1551
+ }
1552
+
1553
+ private async animateCardReflow(
1554
+ previousRectsArg: Map<string, DOMRect>,
1555
+ generationArg: number,
1556
+ ): Promise<void> {
1557
+ await this.updateComplete;
1558
+ if (
1559
+ generationArg !== this.reflowGeneration
1560
+ || !this.isConnected
1561
+ || globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches
1562
+ ) return;
1563
+ this.cancelReflowAnimations();
1564
+ for (const item of this.shadowRoot?.querySelectorAll<HTMLElement>('.item[data-session-id]') ?? []) {
1565
+ const sessionId = item.dataset.sessionId ?? '';
1566
+ if (!sessionId || sessionId === this.draggingSessionId) continue;
1567
+ const previous = previousRectsArg.get(sessionId);
1568
+ if (!previous) continue;
1569
+ const current = item.getBoundingClientRect();
1570
+ const deltaY = previous.top - current.top;
1571
+ if (Math.abs(deltaY) < 0.5) continue;
1572
+ const animation = item.animate(
1573
+ [
1574
+ { transform: `translateY(${deltaY}px)` },
1575
+ { transform: 'translateY(0)' },
1576
+ ],
1577
+ { duration: 180, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' },
1578
+ );
1579
+ this.reflowAnimations.add(animation);
1580
+ void animation.finished.catch(() => undefined).finally(() => {
1581
+ this.reflowAnimations.delete(animation);
1582
+ });
1583
+ }
1584
+ }
1585
+
1586
+ private cancelReflowAnimations(): void {
1587
+ for (const animation of this.reflowAnimations) animation.cancel();
1588
+ this.reflowAnimations.clear();
1589
+ }
1590
+
1591
+ private finishPointerDrag(commitArg: boolean): void {
1592
+ const gesture = this.pointerDragGesture;
1593
+ if (!gesture) return;
1594
+ const detail = commitArg && gesture.phase === 'active' && this.dropSlot
1595
+ ? this.createMoveDetail(gesture.sessionId, this.dropSlot.groupId, this.dropSlot.index)
1596
+ : undefined;
1597
+ this.pointerDragGesture = undefined;
1598
+ window.removeEventListener('keydown', this.handleDragKeyDown, true);
1599
+ window.removeEventListener('blur', this.handleDragWindowBlur);
1600
+ this.stopDragAutoScroll();
1601
+ this.reflowGeneration += 1;
1602
+ this.cancelReflowAnimations();
1603
+ if (gesture.source.matches(':popover-open')) gesture.source.hidePopover();
1604
+ gesture.source.removeAttribute('popover');
1605
+ gesture.source.classList.remove('dragging');
1606
+ for (const property of [
1607
+ '--drag-preview-width',
1608
+ '--drag-preview-height',
1609
+ '--drag-preview-x',
1610
+ '--drag-preview-y',
1611
+ ]) gesture.source.style.removeProperty(property);
1612
+ this.draggingSessionId = '';
1613
+ this.draggedItemHeight = 0;
1614
+ this.dropSlot = undefined;
1615
+ try {
1616
+ if (gesture.handle.hasPointerCapture(gesture.pointerId)) {
1617
+ gesture.handle.releasePointerCapture(gesture.pointerId);
1618
+ }
1619
+ } catch {
1620
+ // Capture may already be released or the handle may have disconnected.
1621
+ }
1622
+ if (detail) {
1623
+ this.dispatchEvent(
1624
+ new CustomEvent('harness-session-move', { detail, bubbles: true, composed: true }),
1625
+ );
1626
+ }
1627
+ }
1628
+
561
1629
  private emit(name: string, session: IHarnessSessionMeta): void {
562
1630
  this.dispatchEvent(new CustomEvent(name, { detail: { session }, bubbles: true, composed: true }));
563
1631
  }