@design.estate/dees-catalog 4.10.0 → 4.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,8 @@ import { harnessFormatRelativeTime } from '../harness.helpers.js';
22
22
  import '../../00group-utility/dees-icon/dees-icon.js';
23
23
 
24
24
  const { repeat } = directives;
25
+ const dragAutoScrollMaxPixelsPerSecond = 900;
26
+ const dragThresholdPixels = 4;
25
27
 
26
28
  declare global {
27
29
  interface HTMLElementTagNameMap {
@@ -34,6 +36,25 @@ interface IDropSlot {
34
36
  index: number;
35
37
  }
36
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
+
37
58
  type TSectionRenderEntry =
38
59
  | { type: 'session'; key: string; session: IHarnessSessionMeta; logicalIndex: number }
39
60
  | { type: 'placeholder'; key: string };
@@ -98,9 +119,27 @@ export class DeesHarnessSessionList extends DeesElement {
98
119
  @state()
99
120
  accessor collapsedGroupIds: ReadonlySet<string> = new Set<string>();
100
121
 
122
+ @state()
123
+ private accessor topScrollShadowVisible = false;
124
+
125
+ @state()
126
+ private accessor bottomScrollShadowVisible = false;
127
+
128
+ @state()
129
+ private accessor moveAnnouncement = '';
130
+
101
131
  private reflowGeneration = 0;
102
132
  private readonly reflowAnimations = new Set<Animation>();
103
- private dragSourceElement: HTMLElement | undefined;
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;
104
143
 
105
144
  public static styles = [
106
145
  themeDefaultStyles,
@@ -161,12 +200,54 @@ export class DeesHarnessSessionList extends DeesElement {
161
200
  }
162
201
 
163
202
  .list {
164
- flex: 1;
165
- min-height: 0;
203
+ width: 100%;
204
+ height: 100%;
166
205
  overflow-y: auto;
167
206
  scrollbar-width: thin;
168
207
  scrollbar-color: var(--dees-color-scrollbar-thumb) transparent;
169
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);
170
251
  }
171
252
 
172
253
  .section {
@@ -178,50 +259,99 @@ export class DeesHarnessSessionList extends DeesElement {
178
259
 
179
260
  .item {
180
261
  --card-accent: var(--session-normal);
262
+ --card-inner-background: color-mix(in srgb, var(--dees-color-bg-primary) 90%, transparent);
263
+ --card-inner-border: transparent;
264
+ --card-lip-width: 3px;
181
265
  position: relative;
182
266
  width: 100%;
183
267
  min-width: 0;
184
268
  overflow: hidden;
185
- border: 1px solid color-mix(in srgb, var(--card-accent) 24%, var(--dees-color-border-subtle));
269
+ border: 0;
186
270
  border-radius: var(--dees-radius-xl);
187
271
  corner-shape: var(--dees-corner-shape);
188
- background: color-mix(in srgb, var(--dees-color-bg-primary) 90%, transparent);
272
+ background: var(--card-accent);
189
273
  color: var(--dees-color-text-primary);
190
- box-shadow:
191
- var(--dees-shadow-sm),
192
- inset 3px 0 0 var(--card-accent);
274
+ box-shadow: var(--dees-shadow-sm);
193
275
  box-sizing: border-box;
276
+ padding-left: var(--card-lip-width);
194
277
  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);
278
+ box-shadow var(--dees-transition-fast) var(--dees-ease-standard),
279
+ padding-left var(--dees-transition-fast) var(--dees-ease-standard);
198
280
  }
199
281
 
200
282
  .item: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%);
283
+ --card-inner-background: color-mix(
284
+ in srgb,
285
+ var(--dees-color-bg-primary) 90%,
286
+ var(--card-accent) 10%
287
+ );
288
+ box-shadow: var(--dees-shadow-md);
203
289
  }
204
290
 
205
291
  .item.selected {
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);
292
+ --card-inner-background: color-mix(
293
+ in srgb,
294
+ var(--dees-color-bg-primary) 86%,
295
+ var(--card-accent) 14%
296
+ );
297
+ --card-inner-border: var(--card-accent);
298
+ --card-lip-width: 16px;
299
+ }
300
+
301
+ .item.selected:hover {
302
+ --card-inner-background: color-mix(
303
+ in srgb,
304
+ var(--dees-color-bg-primary) 82%,
305
+ var(--card-accent) 18%
306
+ );
307
+ }
308
+
309
+ .itemInner {
310
+ min-width: 0;
311
+ overflow: hidden;
312
+ border: 1px solid var(--card-inner-border);
313
+ border-radius: calc(var(--dees-radius-xl) - 1px);
314
+ corner-shape: var(--dees-corner-shape);
315
+ background: var(--card-inner-background);
316
+ box-sizing: border-box;
317
+ transition:
318
+ background var(--dees-transition-fast) var(--dees-ease-standard),
319
+ border-color var(--dees-transition-fast) var(--dees-ease-standard);
211
320
  }
212
321
 
213
322
  .item.dragging {
214
- position: absolute;
215
- inset-inline: 0;
216
- z-index: -1;
217
- opacity: 0;
323
+ position: fixed;
324
+ inset: 0 auto auto 0;
325
+ width: var(--drag-preview-width);
326
+ height: var(--drag-preview-height);
327
+ max-width: none;
328
+ margin: 0;
329
+ padding: 0 0 0 var(--card-lip-width);
330
+ opacity: 0.96;
218
331
  pointer-events: none;
332
+ transform: translate3d(var(--drag-preview-x), var(--drag-preview-y), 0);
333
+ box-shadow: var(--dees-shadow-xl);
219
334
  }
220
335
 
221
336
  .item.state-working {
222
337
  --card-accent: var(--session-working);
223
338
  }
224
339
 
340
+ @keyframes workingDotPulse {
341
+ 0%, 100% {
342
+ transform: scale(0.92);
343
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--session-working) 14%, transparent);
344
+ }
345
+ 50% {
346
+ transform: scale(1.08);
347
+ box-shadow: 0 0 0 6px color-mix(in srgb, var(--session-working) 3%, transparent);
348
+ }
349
+ }
350
+
351
+ .item.state-working .itemHeader .stateDot {
352
+ animation: workingDotPulse 2.4s ease-in-out infinite;
353
+ }
354
+
225
355
  .item.state-finished {
226
356
  --card-accent: var(--session-finished);
227
357
  }
@@ -236,29 +366,54 @@ export class DeesHarnessSessionList extends DeesElement {
236
366
 
237
367
  @keyframes attentionPulse {
238
368
  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);
369
+ background: color-mix(in srgb, var(--dees-color-bg-primary) 94%, var(--session-attention) 6%);
243
370
  }
244
371
  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);
372
+ background: color-mix(in srgb, var(--dees-color-bg-primary) 84%, var(--session-attention) 16%);
249
373
  }
250
374
  }
251
375
 
252
- .item.state-attention:not(.selected) {
376
+ .item.state-attention .itemInner {
253
377
  animation: attentionPulse 1.15s ease-in-out infinite;
254
378
  }
255
379
 
380
+ .item.state-attention:hover .itemInner {
381
+ animation: none;
382
+ background: color-mix(in srgb, var(--dees-color-bg-primary) 82%, var(--session-attention) 18%);
383
+ }
384
+
256
385
  .itemHeader {
257
386
  display: grid;
258
387
  grid-template-columns: minmax(0, 1fr) 30px;
259
388
  min-height: 46px;
260
389
  }
261
390
 
391
+ .itemHeader.draggable {
392
+ grid-template-columns: 24px minmax(0, 1fr) 30px;
393
+ }
394
+
395
+ .dragHandle {
396
+ appearance: none;
397
+ display: grid;
398
+ place-items: center;
399
+ padding: 0;
400
+ border: 0;
401
+ background: transparent;
402
+ color: var(--dees-color-text-muted);
403
+ cursor: grab;
404
+ touch-action: none;
405
+ }
406
+
407
+ .dragHandle:active {
408
+ cursor: grabbing;
409
+ }
410
+
411
+ .dragHandle:focus-visible {
412
+ outline: 2px solid var(--dees-color-accent-primary);
413
+ outline-offset: -3px;
414
+ border-radius: 8px;
415
+ }
416
+
262
417
  .itemPrimary,
263
418
  .expandToggle {
264
419
  appearance: none;
@@ -497,7 +652,10 @@ export class DeesHarnessSessionList extends DeesElement {
497
652
 
498
653
  @media (prefers-reduced-motion: reduce) {
499
654
  .item,
500
- .item.state-attention,
655
+ .itemInner,
656
+ .item.state-attention .itemInner,
657
+ .item.state-working .itemHeader .stateDot,
658
+ .scrollShadow,
501
659
  .expandToggle dees-icon {
502
660
  animation: none;
503
661
  transition: none;
@@ -557,15 +715,21 @@ export class DeesHarnessSessionList extends DeesElement {
557
715
 
558
716
  public updated(changedProperties: Map<PropertyKey, unknown>): void {
559
717
  super.updated(changedProperties);
560
- if (this.draggingSessionId && !this.dragSourceElement?.isConnected) {
561
- this.clearDragState();
718
+ this.observeScrollGeometry();
719
+ this.syncScrollShadowState();
720
+ this.restoreKeyboardMoveFocus();
721
+ if (
722
+ this.pointerDragGesture
723
+ && (!this.pointerDragGesture.source.isConnected || !this.pointerDragGesture.handle.isConnected)
724
+ ) {
725
+ this.finishPointerDrag(false);
562
726
  }
563
727
  if (!changedProperties.has('sessions')) return;
564
728
  const previousSessions = changedProperties.get('sessions') as IHarnessSessionMeta[] | undefined;
565
729
  const previousById = new Map((previousSessions ?? []).map((session) => [session.id, session]));
566
730
  const knownIds = new Set(this.sessions.map((session) => session.id));
567
- if (this.draggingSessionId && !knownIds.has(this.draggingSessionId)) {
568
- this.clearDragState();
731
+ if (this.pointerDragGesture && !knownIds.has(this.pointerDragGesture.sessionId)) {
732
+ this.finishPointerDrag(false);
569
733
  }
570
734
  const nextExpanded = new Set(
571
735
  [...this.expandedSessionIds].filter((sessionId) => knownIds.has(sessionId)),
@@ -584,8 +748,20 @@ export class DeesHarnessSessionList extends DeesElement {
584
748
  }
585
749
  }
586
750
 
751
+ public async connectedCallback(): Promise<void> {
752
+ await super.connectedCallback();
753
+ await this.updateComplete;
754
+ if (!this.isConnected) return;
755
+ this.observeScrollGeometry();
756
+ this.syncScrollShadowState();
757
+ }
758
+
587
759
  public async disconnectedCallback(): Promise<void> {
588
- this.clearDragState();
760
+ this.finishPointerDrag(false);
761
+ this.scrollResizeObserver?.disconnect();
762
+ this.scrollResizeObserver = undefined;
763
+ this.observedScrollContainer = undefined;
764
+ this.observedScrollContent = undefined;
589
765
  await super.disconnectedCallback();
590
766
  }
591
767
 
@@ -602,13 +778,23 @@ export class DeesHarnessSessionList extends DeesElement {
602
778
  }}
603
779
  />
604
780
  </div>
605
- <div class="list">
606
- ${this.loading
607
- ? html`<div class="emptyNote">Loading conversations…</div>`
608
- : groupingActive
609
- ? this.renderGroupedList()
610
- : this.renderFlatList()}
781
+ <div class="listFrame">
782
+ <div class="scrollShadow top ${this.topScrollShadowVisible ? 'visible' : ''}"></div>
783
+ <div
784
+ class="list"
785
+ @scroll=${this.syncScrollShadowState}
786
+ >
787
+ <div class="listContent">
788
+ ${this.loading
789
+ ? html`<div class="emptyNote">Loading conversations…</div>`
790
+ : groupingActive
791
+ ? this.renderGroupedList()
792
+ : this.renderFlatList()}
793
+ </div>
794
+ </div>
795
+ <div class="scrollShadow bottom ${this.bottomScrollShadowVisible ? 'visible' : ''}"></div>
611
796
  </div>
797
+ <div class="visuallyHidden" aria-live="polite">${this.moveAnnouncement}</div>
612
798
  ${groupingActive
613
799
  ? html`
614
800
  <div class="newGroupRow">
@@ -670,6 +856,9 @@ export class DeesHarnessSessionList extends DeesElement {
670
856
  return html`
671
857
  <button
672
858
  class="groupHeader ${collapsed ? 'collapsed' : ''} ${isHeaderDrop ? 'dropTarget' : ''}"
859
+ data-drop-group-id=${group.id}
860
+ data-drop-index=${this.sortableSessions(members).length}
861
+ data-drop-kind="slot"
673
862
  type="button"
674
863
  @click=${() => this.toggleGroupCollapsed(group.id)}
675
864
  @contextmenu=${(event: MouseEvent) => {
@@ -684,16 +873,6 @@ export class DeesHarnessSessionList extends DeesElement {
684
873
  new CustomEvent('harness-group-context', { detail, bubbles: true, composed: true }),
685
874
  );
686
875
  }}
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
- )}
697
876
  >
698
877
  <dees-icon class="chevron" icon="lucide:ChevronDown" iconSize="12"></dees-icon>
699
878
  <span>${group.name}</span>
@@ -708,10 +887,11 @@ export class DeesHarnessSessionList extends DeesElement {
708
887
  ? html`
709
888
  <button
710
889
  class="groupHeader"
890
+ data-drop-group-id=""
891
+ data-drop-index="0"
892
+ data-drop-kind="slot"
711
893
  type="button"
712
894
  tabindex="-1"
713
- @dragover=${(event: DragEvent) => this.handleDragOverSlot(event, null, 0)}
714
- @drop=${(event: DragEvent) => this.handleDrop(event, null, 0)}
715
895
  >
716
896
  <span>Ungrouped</span>
717
897
  <span class="groupCount">${ungrouped.length}</span>
@@ -766,8 +946,9 @@ export class DeesHarnessSessionList extends DeesElement {
766
946
  ? html`
767
947
  <div
768
948
  class="emptyDropZone"
769
- @dragover=${(event: DragEvent) => this.handleDragOverSlot(event, groupId, 0)}
770
- @drop=${(event: DragEvent) => this.handleDrop(event, groupId, 0)}
949
+ data-drop-group-id=${groupId ?? ''}
950
+ data-drop-index="0"
951
+ data-drop-kind="slot"
771
952
  ></div>
772
953
  `
773
954
  : ''}
@@ -779,9 +960,10 @@ export class DeesHarnessSessionList extends DeesElement {
779
960
  return html`
780
961
  <div
781
962
  class="dropPlaceholder"
963
+ data-drop-group-id=${groupId ?? ''}
964
+ data-drop-index=${index}
965
+ data-drop-kind="slot"
782
966
  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
967
  ></div>
786
968
  `;
787
969
  }
@@ -795,11 +977,11 @@ export class DeesHarnessSessionList extends DeesElement {
795
977
  this.enableGrouping
796
978
  && session.draggable !== false
797
979
  && index >= 0
798
- && !globalThis.matchMedia?.('(pointer: coarse)').matches
799
980
  );
800
981
  const expanded = this.expandedSessionIds.has(session.id);
801
982
  const state = session.state ?? 'normal';
802
983
  const detailsId = `session-details-${session.id}`;
984
+ const dropIndex = index < 0 ? 0 : index;
803
985
  return html`
804
986
  <article
805
987
  class="item state-${state} ${expanded ? 'expanded' : ''} ${
@@ -808,9 +990,11 @@ export class DeesHarnessSessionList extends DeesElement {
808
990
  this.draggingSessionId === session.id ? 'dragging' : ''
809
991
  }"
810
992
  data-session-id=${session.id}
811
- .draggable=${draggable}
993
+ data-drop-group-id=${groupId ?? ''}
994
+ data-drop-index=${dropIndex}
995
+ data-drop-kind=${index < 0 ? 'slot' : 'card'}
812
996
  @dblclick=${(event: MouseEvent) => {
813
- if (this.eventTargetsExpandToggle(event)) return;
997
+ if (this.eventTargetsExpandToggle(event) || this.eventTargetsDragHandle(event)) return;
814
998
  this.emit('harness-session-open', session);
815
999
  }}
816
1000
  @contextmenu=${(event: MouseEvent) => {
@@ -826,76 +1010,84 @@ export class DeesHarnessSessionList extends DeesElement {
826
1010
  new CustomEvent('harness-session-context', { detail, bubbles: true, composed: true }),
827
1011
  );
828
1012
  }}
829
- @dragstart=${(event: DragEvent) => {
830
- this.handleDragStart(event, session, groupId, index, draggable);
831
- }}
832
- @dragend=${() => this.clearDragState()}
833
- @dragover=${(event: DragEvent) => {
834
- if (!this.draggingSessionId || index < 0 || session.id === this.draggingSessionId) return;
835
- event.preventDefault();
836
- const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
837
- const before = event.clientY < rect.top + rect.height / 2;
838
- this.setDropSlot(groupId, before ? index : index + 1);
839
- }}
840
- @drop=${(event: DragEvent) => {
841
- if (index < 0 || session.id === this.draggingSessionId) return;
842
- const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
843
- const before = event.clientY < rect.top + rect.height / 2;
844
- this.handleDrop(event, groupId, before ? index : index + 1);
845
- }}
846
1013
  >
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>
1014
+ <div class="itemInner">
1015
+ <div class="itemHeader ${draggable ? 'draggable' : ''}">
1016
+ ${draggable
1017
+ ? html`
1018
+ <button
1019
+ class="dragHandle"
1020
+ type="button"
1021
+ title="Drag to reorder; use arrow, Home, or End keys for keyboard reordering"
1022
+ aria-label=${`Reorder ${session.title || session.id}`}
1023
+ aria-keyshortcuts="ArrowUp ArrowDown Home End"
1024
+ @pointerdown=${(event: PointerEvent) => {
1025
+ this.handlePointerDown(event, session, groupId, index);
1026
+ }}
1027
+ @pointermove=${this.handlePointerMove}
1028
+ @pointerup=${this.handlePointerUp}
1029
+ @pointercancel=${this.handlePointerCancel}
1030
+ @lostpointercapture=${this.handleLostPointerCapture}
1031
+ @keydown=${(event: KeyboardEvent) => {
1032
+ this.handleKeyboardMove(event, session, groupId);
1033
+ }}
1034
+ >
1035
+ <dees-icon icon="lucide:GripVertical" iconSize="14"></dees-icon>
1036
+ </button>
1037
+ `
1038
+ : ''}
1039
+ <button
1040
+ class="itemPrimary"
1041
+ type="button"
1042
+ aria-current=${session.id === this.selectedSessionId ? 'true' : 'false'}
1043
+ @click=${() => this.emit('harness-session-select', session)}
1044
+ @keydown=${(event: KeyboardEvent) => {
1045
+ if (event.key !== 'Enter') return;
1046
+ event.preventDefault();
1047
+ this.emit('harness-session-open', session);
1048
+ }}
1049
+ >
1050
+ <span class="stateDot"></span>
1051
+ <span class="itemCopy">
1052
+ <span class="itemTitle">
1053
+ ${session.icon
1054
+ ? html`<dees-icon class="itemIcon" icon=${session.icon} iconSize="13"></dees-icon>`
1055
+ : ''}
1056
+ <span class="titleText">${session.title || session.id}</span>
1057
+ </span>
1058
+ <span class="itemTime">${harnessFormatRelativeTime(session.updatedAt ?? session.createdAt)}</span>
1059
+ <span class="visuallyHidden">Status: ${this.sessionStateLabel(state)}</span>
866
1060
  </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}
1061
+ </button>
1062
+ <button
1063
+ class="expandToggle"
1064
+ type="button"
1065
+ aria-expanded=${expanded ? 'true' : 'false'}
1066
+ aria-controls=${detailsId}
1067
+ title=${expanded ? 'Collapse conversation card' : 'Expand conversation card'}
1068
+ @click=${(event: MouseEvent) => {
1069
+ event.stopPropagation();
1070
+ this.toggleSessionExpanded(session.id);
1071
+ }}
1072
+ >
1073
+ <dees-icon icon="lucide:ChevronDown" iconSize="15"></dees-icon>
1074
+ </button>
1075
+ </div>
1076
+ ${expanded
1077
+ ? html`
1078
+ <div class="itemDetails" id=${detailsId}>
1079
+ <div class="stateLine">
1080
+ <span class="stateDot"></span>
1081
+ <span>${this.sessionStateLabel(state)}</span>
1082
+ </div>
1083
+ ${session.preview ? html`<div class="itemPreview">${session.preview}</div>` : ''}
1084
+ <div class="itemMeta">
1085
+ ${session.messageCount ? `${session.messageCount} messages · ` : ''}${session.id}
1086
+ </div>
895
1087
  </div>
896
- </div>
897
- `
898
- : ''}
1088
+ `
1089
+ : ''}
1090
+ </div>
899
1091
  </article>
900
1092
  `;
901
1093
  }
@@ -906,6 +1098,12 @@ export class DeesHarnessSessionList extends DeesElement {
906
1098
  );
907
1099
  }
908
1100
 
1101
+ private eventTargetsDragHandle(event: Event): boolean {
1102
+ return event.composedPath().some(
1103
+ (target) => target instanceof HTMLElement && target.classList.contains('dragHandle'),
1104
+ );
1105
+ }
1106
+
909
1107
  private sessionStateLabel(state: NonNullable<IHarnessSessionMeta['state']>): string {
910
1108
  switch (state) {
911
1109
  case 'working': return 'Working';
@@ -953,27 +1151,139 @@ export class DeesHarnessSessionList extends DeesElement {
953
1151
  .filter((session): session is IHarnessSessionMeta => session !== undefined);
954
1152
  }
955
1153
 
956
- private handleDragStart(
957
- event: DragEvent,
958
- session: IHarnessSessionMeta,
959
- groupId: string | null,
960
- logicalIndex: number,
961
- draggable: boolean,
1154
+ private handlePointerDown(
1155
+ eventArg: PointerEvent,
1156
+ sessionArg: IHarnessSessionMeta,
1157
+ groupIdArg: string | null,
1158
+ logicalIndexArg: number,
962
1159
  ): void {
963
- if (!draggable || logicalIndex < 0) {
964
- event.preventDefault();
1160
+ if (
1161
+ this.pointerDragGesture
1162
+ || eventArg.button !== 0
1163
+ || !eventArg.isPrimary
1164
+ || logicalIndexArg < 0
1165
+ ) return;
1166
+ const handle = eventArg.currentTarget as HTMLElement;
1167
+ const source = handle.closest<HTMLElement>('.item');
1168
+ if (!source) return;
1169
+ const sourceRect = source.getBoundingClientRect();
1170
+ this.pointerDragGesture = {
1171
+ pointerId: eventArg.pointerId,
1172
+ handle,
1173
+ source,
1174
+ sessionId: sessionArg.id,
1175
+ sourceGroupId: groupIdArg,
1176
+ sourceLogicalIndex: logicalIndexArg,
1177
+ startClientX: eventArg.clientX,
1178
+ startClientY: eventArg.clientY,
1179
+ grabOffsetX: eventArg.clientX - sourceRect.left,
1180
+ grabOffsetY: eventArg.clientY - sourceRect.top,
1181
+ phase: 'pending',
1182
+ };
1183
+ window.addEventListener('keydown', this.handleDragKeyDown, true);
1184
+ window.addEventListener('blur', this.handleDragWindowBlur);
1185
+ try {
1186
+ handle.setPointerCapture(eventArg.pointerId);
1187
+ } catch {
1188
+ this.finishPointerDrag(false);
1189
+ }
1190
+ }
1191
+
1192
+ private readonly handlePointerMove = (eventArg: PointerEvent): void => {
1193
+ const gesture = this.pointerDragGesture;
1194
+ if (!gesture || eventArg.pointerId !== gesture.pointerId) return;
1195
+ if (!gesture.source.isConnected || !gesture.handle.isConnected) {
1196
+ this.finishPointerDrag(false);
965
1197
  return;
966
1198
  }
967
- const item = event.currentTarget as HTMLElement;
1199
+ if (
1200
+ gesture.phase === 'pending'
1201
+ && Math.hypot(
1202
+ eventArg.clientX - gesture.startClientX,
1203
+ eventArg.clientY - gesture.startClientY,
1204
+ ) < dragThresholdPixels
1205
+ ) return;
1206
+ if (gesture.phase === 'pending' && !this.startPointerDrag(eventArg, gesture)) return;
1207
+ eventArg.preventDefault();
1208
+ this.moveDragPreview(eventArg.clientX, eventArg.clientY, gesture);
1209
+ this.dragAutoScrollClientX = eventArg.clientX;
1210
+ this.dragAutoScrollClientY = eventArg.clientY;
1211
+ this.updateDragAutoScrollVelocity();
1212
+ this.updateDropSlotFromPointer();
1213
+ };
1214
+
1215
+ private readonly handlePointerUp = (eventArg: PointerEvent): void => {
1216
+ const gesture = this.pointerDragGesture;
1217
+ if (!gesture || eventArg.pointerId !== gesture.pointerId) return;
1218
+ if (gesture.phase === 'active') {
1219
+ this.dragAutoScrollClientX = eventArg.clientX;
1220
+ this.dragAutoScrollClientY = eventArg.clientY;
1221
+ this.moveDragPreview(eventArg.clientX, eventArg.clientY, gesture);
1222
+ this.updateDropSlotFromPointer();
1223
+ this.finishPointerDrag(true);
1224
+ return;
1225
+ }
1226
+ this.finishPointerDrag(false);
1227
+ };
1228
+
1229
+ private readonly handlePointerCancel = (eventArg: PointerEvent): void => {
1230
+ if (eventArg.pointerId === this.pointerDragGesture?.pointerId) this.finishPointerDrag(false);
1231
+ };
1232
+
1233
+ private readonly handleLostPointerCapture = (eventArg: PointerEvent): void => {
1234
+ if (eventArg.pointerId === this.pointerDragGesture?.pointerId) this.finishPointerDrag(false);
1235
+ };
1236
+
1237
+ private readonly handleDragKeyDown = (eventArg: KeyboardEvent): void => {
1238
+ if (eventArg.key !== 'Escape' || !this.pointerDragGesture) return;
1239
+ eventArg.preventDefault();
1240
+ eventArg.stopPropagation();
1241
+ this.finishPointerDrag(false);
1242
+ };
1243
+
1244
+ private readonly handleDragWindowBlur = (): void => {
1245
+ this.finishPointerDrag(false);
1246
+ };
1247
+
1248
+ private startPointerDrag(eventArg: PointerEvent, gestureArg: IPointerDragGesture): boolean {
1249
+ const sourceRect = gestureArg.source.getBoundingClientRect();
1250
+ gestureArg.source.classList.add('dragging');
1251
+ gestureArg.source.style.setProperty('--drag-preview-width', `${sourceRect.width}px`);
1252
+ gestureArg.source.style.setProperty('--drag-preview-height', `${sourceRect.height}px`);
1253
+ this.moveDragPreview(eventArg.clientX, eventArg.clientY, gestureArg);
1254
+ gestureArg.source.setAttribute('popover', 'manual');
1255
+ try {
1256
+ gestureArg.source.showPopover();
1257
+ } catch {
1258
+ this.finishPointerDrag(false);
1259
+ return false;
1260
+ }
1261
+ gestureArg.phase = 'active';
968
1262
  this.reflowGeneration += 1;
969
1263
  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';
1264
+ this.draggedItemHeight = Math.ceil(sourceRect.height);
1265
+ this.draggingSessionId = gestureArg.sessionId;
1266
+ const remainingCount = this.sortableSessions(this.sessionsForGroup(gestureArg.sourceGroupId)).length;
1267
+ this.dropSlot = {
1268
+ groupId: gestureArg.sourceGroupId,
1269
+ index: Math.min(gestureArg.sourceLogicalIndex, remainingCount),
1270
+ };
1271
+ return true;
1272
+ }
1273
+
1274
+ private moveDragPreview(
1275
+ clientXArg: number,
1276
+ clientYArg: number,
1277
+ gestureArg: IPointerDragGesture,
1278
+ ): void {
1279
+ gestureArg.source.style.setProperty(
1280
+ '--drag-preview-x',
1281
+ `${clientXArg - gestureArg.grabOffsetX}px`,
1282
+ );
1283
+ gestureArg.source.style.setProperty(
1284
+ '--drag-preview-y',
1285
+ `${clientYArg - gestureArg.grabOffsetY}px`,
1286
+ );
977
1287
  }
978
1288
 
979
1289
  private setDropSlot(groupIdArg: string | null, indexArg: number): void {
@@ -985,38 +1295,239 @@ export class DeesHarnessSessionList extends DeesElement {
985
1295
  void this.animateCardReflow(previousRects, generation);
986
1296
  }
987
1297
 
988
- private handleDragOverSlot(event: DragEvent, groupIdArg: string | null, indexArg: number): void {
989
- if (!this.draggingSessionId) return;
990
- event.preventDefault();
991
- if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
992
- this.setDropSlot(groupIdArg, indexArg);
1298
+ private clearDropSlot(): void {
1299
+ if (!this.dropSlot) return;
1300
+ const previousRects = this.captureCardRects();
1301
+ const generation = ++this.reflowGeneration;
1302
+ this.dropSlot = undefined;
1303
+ void this.animateCardReflow(previousRects, generation);
1304
+ }
1305
+
1306
+ private updateDragAutoScrollVelocity(): void {
1307
+ const container = this.scrollContainer;
1308
+ if (!container || !this.draggingSessionId) return;
1309
+ const rect = container.getBoundingClientRect();
1310
+ if (this.dragAutoScrollClientX < rect.left || this.dragAutoScrollClientX > rect.right) {
1311
+ this.stopDragAutoScroll();
1312
+ return;
1313
+ }
1314
+ const edgeSize = Math.min(64, Math.max(32, rect.height * 0.25));
1315
+ const topDepth = Math.max(
1316
+ 0,
1317
+ Math.min(1, (rect.top + edgeSize - this.dragAutoScrollClientY) / edgeSize),
1318
+ );
1319
+ const bottomDepth = Math.max(
1320
+ 0,
1321
+ Math.min(1, (this.dragAutoScrollClientY - (rect.bottom - edgeSize)) / edgeSize),
1322
+ );
1323
+ this.dragAutoScrollVelocity = bottomDepth > 0
1324
+ ? dragAutoScrollMaxPixelsPerSecond * bottomDepth * bottomDepth
1325
+ : topDepth > 0
1326
+ ? -dragAutoScrollMaxPixelsPerSecond * topDepth * topDepth
1327
+ : 0;
1328
+ if (this.dragAutoScrollVelocity === 0) {
1329
+ this.stopDragAutoScroll();
1330
+ return;
1331
+ }
1332
+ if (this.dragAutoScrollFrame === undefined) {
1333
+ this.dragAutoScrollLastFrame = performance.now();
1334
+ this.dragAutoScrollFrame = requestAnimationFrame(this.runDragAutoScroll);
1335
+ }
993
1336
  }
994
1337
 
995
- private handleDrop(event: DragEvent, groupIdArg: string | null, indexArg: number): void {
996
- event.preventDefault();
997
- event.stopPropagation();
998
- const sessionId = this.draggingSessionId || event.dataTransfer?.getData('text/plain') || '';
999
- if (!sessionId) {
1000
- this.clearDragState();
1338
+ private readonly runDragAutoScroll = (timestampArg: number): void => {
1339
+ this.dragAutoScrollFrame = undefined;
1340
+ const container = this.scrollContainer;
1341
+ if (!container || !this.draggingSessionId || this.dragAutoScrollVelocity === 0) return;
1342
+ const elapsedMs = Math.min(34, Math.max(0, timestampArg - this.dragAutoScrollLastFrame));
1343
+ this.dragAutoScrollLastFrame = timestampArg;
1344
+ const previousScrollTop = container.scrollTop;
1345
+ container.scrollTop += this.dragAutoScrollVelocity * elapsedMs / 1000;
1346
+ this.syncScrollShadowState();
1347
+ const atScrollBoundary = this.dragAutoScrollVelocity < 0
1348
+ ? container.scrollTop <= 0
1349
+ : container.scrollTop + container.clientHeight >= container.scrollHeight - 1;
1350
+ if (container.scrollTop === previousScrollTop && atScrollBoundary) {
1351
+ this.stopDragAutoScroll();
1001
1352
  return;
1002
1353
  }
1354
+ this.updateDropSlotFromPointer();
1355
+ this.dragAutoScrollFrame = requestAnimationFrame(this.runDragAutoScroll);
1356
+ };
1357
+
1358
+ private updateDropSlotFromPointer(): void {
1359
+ const pointerX = this.dragAutoScrollClientX;
1360
+ const pointerY = this.dragAutoScrollClientY;
1361
+ const placeholder = this.shadowRoot?.querySelector<HTMLElement>('.dropPlaceholder');
1362
+ if (placeholder) {
1363
+ const rect = placeholder.getBoundingClientRect();
1364
+ if (
1365
+ pointerX >= rect.left
1366
+ && pointerX <= rect.right
1367
+ && pointerY >= rect.top
1368
+ && pointerY <= rect.bottom
1369
+ ) {
1370
+ this.setDropSlot(
1371
+ placeholder.dataset.dropGroupId || null,
1372
+ Number(placeholder.dataset.dropIndex),
1373
+ );
1374
+ return;
1375
+ }
1376
+ }
1377
+ const selector = '[data-drop-group-id][data-drop-index][data-drop-kind]';
1378
+ let target: HTMLElement | undefined;
1379
+ const offsets = this.dragAutoScrollVelocity === 0
1380
+ ? [0, -8, 8, -16, 16, -32, 32]
1381
+ : [0, 8, 16, 32, 48, 64, 96].map(
1382
+ (value) => value * (this.dragAutoScrollVelocity < 0 ? 1 : -1),
1383
+ );
1384
+ for (const offset of offsets) {
1385
+ const hit = this.shadowRoot?.elementFromPoint(pointerX, pointerY + offset);
1386
+ const candidate = hit?.closest<HTMLElement>(selector);
1387
+ if (
1388
+ candidate
1389
+ && candidate.dataset.sessionId !== this.draggingSessionId
1390
+ && Number(candidate.dataset.dropIndex) >= 0
1391
+ ) {
1392
+ target = candidate;
1393
+ break;
1394
+ }
1395
+ }
1396
+ if (!target) {
1397
+ this.clearDropSlot();
1398
+ return;
1399
+ }
1400
+ const baseIndex = Number(target.dataset.dropIndex);
1401
+ const groupId = target.dataset.dropGroupId || null;
1402
+ const rect = target.getBoundingClientRect();
1403
+ const index = target.dataset.dropKind === 'card' && pointerY >= rect.top + rect.height / 2
1404
+ ? baseIndex + 1
1405
+ : baseIndex;
1406
+ this.setDropSlot(groupId, index);
1407
+ }
1408
+
1409
+ private get scrollContainer(): HTMLElement | undefined {
1410
+ return this.shadowRoot?.querySelector<HTMLElement>('.list') ?? undefined;
1411
+ }
1412
+
1413
+ private observeScrollGeometry(): void {
1414
+ if (!this.isConnected) return;
1415
+ const container = this.scrollContainer;
1416
+ const content = this.shadowRoot?.querySelector<HTMLElement>('.listContent') ?? undefined;
1417
+ if (!container || !content) return;
1418
+ if (
1419
+ this.scrollResizeObserver
1420
+ && this.observedScrollContainer === container
1421
+ && this.observedScrollContent === content
1422
+ ) return;
1423
+ this.scrollResizeObserver?.disconnect();
1424
+ this.scrollResizeObserver = new ResizeObserver(this.syncScrollShadowState);
1425
+ this.scrollResizeObserver.observe(container);
1426
+ this.scrollResizeObserver.observe(content);
1427
+ this.observedScrollContainer = container;
1428
+ this.observedScrollContent = content;
1429
+ }
1430
+
1431
+ private readonly syncScrollShadowState = (): void => {
1432
+ const container = this.scrollContainer;
1433
+ if (!container) return;
1434
+ const topVisible = container.scrollTop > 1;
1435
+ const bottomVisible = container.scrollTop + container.clientHeight < container.scrollHeight - 1;
1436
+ if (this.topScrollShadowVisible !== topVisible) this.topScrollShadowVisible = topVisible;
1437
+ if (this.bottomScrollShadowVisible !== bottomVisible) this.bottomScrollShadowVisible = bottomVisible;
1438
+ };
1439
+
1440
+ private stopDragAutoScroll(): void {
1441
+ if (this.dragAutoScrollFrame !== undefined) cancelAnimationFrame(this.dragAutoScrollFrame);
1442
+ this.dragAutoScrollFrame = undefined;
1443
+ this.dragAutoScrollVelocity = 0;
1444
+ this.dragAutoScrollLastFrame = 0;
1445
+ }
1446
+
1447
+ private createMoveDetail(
1448
+ sessionIdArg: string,
1449
+ groupIdArg: string | null,
1450
+ indexArg: number,
1451
+ ): IHarnessSessionMoveDetail {
1003
1452
  const targetSessions = this.sortableSessions(this.sessionsForGroup(groupIdArg));
1004
1453
  const beforeSessionId = targetSessions[Math.max(0, indexArg)]?.id;
1005
- const sourceGroupId = this.groups.find((group) => group.sessionIds.includes(sessionId))?.id ?? null;
1454
+ const sourceGroupId = this.groups.find((group) => group.sessionIds.includes(sessionIdArg))?.id ?? null;
1006
1455
  const sourceSessions = this.sortableSessions(this.sessionsForGroup(sourceGroupId), false);
1007
- const sourceIndex = sourceSessions.findIndex((session) => session.id === sessionId);
1456
+ const sourceIndex = sourceSessions.findIndex((session) => session.id === sessionIdArg);
1008
1457
  const compatibilityIndex = (
1009
1458
  sourceGroupId === groupIdArg
1010
1459
  && sourceIndex >= 0
1011
1460
  && indexArg > sourceIndex
1012
1461
  ) ? indexArg + 1 : indexArg;
1013
- const detail: IHarnessSessionMoveDetail = {
1014
- sessionId,
1462
+ return {
1463
+ sessionId: sessionIdArg,
1015
1464
  groupId: groupIdArg,
1016
1465
  index: Math.max(0, compatibilityIndex),
1017
1466
  ...(beforeSessionId ? { beforeSessionId } : {}),
1018
1467
  };
1019
- this.clearDragState();
1468
+ }
1469
+
1470
+ private handleKeyboardMove(
1471
+ eventArg: KeyboardEvent,
1472
+ sessionArg: IHarnessSessionMeta,
1473
+ groupIdArg: string | null,
1474
+ ): void {
1475
+ if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(eventArg.key)) return;
1476
+ eventArg.preventDefault();
1477
+ eventArg.stopPropagation();
1478
+ const sessions = this.sortableSessions(this.sessionsForGroup(groupIdArg), false);
1479
+ const sourceIndex = sessions.findIndex((session) => session.id === sessionArg.id);
1480
+ if (sourceIndex < 0) return;
1481
+ const sectionIds: Array<string | null> = [...this.groups.map((group) => group.id), null];
1482
+ const sectionIndex = sectionIds.indexOf(groupIdArg);
1483
+ const adjacentGroupId = eventArg.key === 'ArrowUp' && sourceIndex === 0 && sectionIndex > 0
1484
+ ? sectionIds[sectionIndex - 1]
1485
+ : eventArg.key === 'ArrowDown'
1486
+ && sourceIndex === sessions.length - 1
1487
+ && sectionIndex >= 0
1488
+ && sectionIndex < sectionIds.length - 1
1489
+ ? sectionIds[sectionIndex + 1]
1490
+ : undefined;
1491
+ if (adjacentGroupId !== undefined) {
1492
+ const targetSessions = this.sortableSessions(this.sessionsForGroup(adjacentGroupId), false);
1493
+ const targetIndex = eventArg.key === 'ArrowUp' ? targetSessions.length : 0;
1494
+ const beforeSession = targetSessions[targetIndex];
1495
+ const detail: IHarnessSessionMoveDetail = {
1496
+ sessionId: sessionArg.id,
1497
+ groupId: adjacentGroupId,
1498
+ index: targetIndex,
1499
+ ...(beforeSession ? { beforeSessionId: beforeSession.id } : {}),
1500
+ };
1501
+ const groupName = adjacentGroupId === null
1502
+ ? 'Ungrouped'
1503
+ : this.groups.find((group) => group.id === adjacentGroupId)?.name ?? adjacentGroupId;
1504
+ this.moveAnnouncement = `Moved ${sessionArg.title || sessionArg.id} to ${groupName}.`;
1505
+ this.keyboardMoveFocus = { sessionId: sessionArg.id, groupId: adjacentGroupId };
1506
+ this.dispatchEvent(
1507
+ new CustomEvent('harness-session-move', { detail, bubbles: true, composed: true }),
1508
+ );
1509
+ return;
1510
+ }
1511
+ const remaining = sessions.filter((session) => session.id !== sessionArg.id);
1512
+ const targetIndex = eventArg.key === 'Home'
1513
+ ? 0
1514
+ : eventArg.key === 'End'
1515
+ ? remaining.length
1516
+ : eventArg.key === 'ArrowUp'
1517
+ ? Math.max(0, sourceIndex - 1)
1518
+ : Math.min(remaining.length, sourceIndex + 1);
1519
+ if (targetIndex === sourceIndex) return;
1520
+ const beforeSession = remaining[targetIndex];
1521
+ const compatibilityIndex = targetIndex > sourceIndex ? targetIndex + 1 : targetIndex;
1522
+ const detail: IHarnessSessionMoveDetail = {
1523
+ sessionId: sessionArg.id,
1524
+ groupId: groupIdArg,
1525
+ index: compatibilityIndex,
1526
+ ...(beforeSession ? { beforeSessionId: beforeSession.id } : {}),
1527
+ };
1528
+ this.moveAnnouncement = beforeSession
1529
+ ? `Moved ${sessionArg.title || sessionArg.id} before ${beforeSession.title || beforeSession.id}.`
1530
+ : `Moved ${sessionArg.title || sessionArg.id} to the end.`;
1020
1531
  this.dispatchEvent(
1021
1532
  new CustomEvent('harness-session-move', { detail, bubbles: true, composed: true }),
1022
1533
  );
@@ -1031,6 +1542,31 @@ export class DeesHarnessSessionList extends DeesElement {
1031
1542
  return result;
1032
1543
  }
1033
1544
 
1545
+ private restoreKeyboardMoveFocus(): void {
1546
+ const pending = this.keyboardMoveFocus;
1547
+ if (!pending) return;
1548
+ const currentGroupId = this.groups.find(
1549
+ (group) => group.sessionIds.includes(pending.sessionId),
1550
+ )?.id ?? null;
1551
+ if (currentGroupId !== pending.groupId) return;
1552
+ const item = Array.from(
1553
+ this.shadowRoot?.querySelectorAll<HTMLElement>('.item[data-session-id]') ?? [],
1554
+ ).find((candidate) => candidate.dataset.sessionId === pending.sessionId);
1555
+ const handle = item?.querySelector<HTMLElement>('.dragHandle');
1556
+ if (handle) {
1557
+ handle.focus();
1558
+ this.keyboardMoveFocus = undefined;
1559
+ return;
1560
+ }
1561
+ const groupHeader = Array.from(
1562
+ this.shadowRoot?.querySelectorAll<HTMLElement>('.groupHeader[data-drop-group-id]') ?? [],
1563
+ ).find((candidate) => candidate.dataset.dropGroupId === (pending.groupId ?? ''));
1564
+ if (groupHeader) {
1565
+ groupHeader.focus();
1566
+ this.keyboardMoveFocus = undefined;
1567
+ }
1568
+ }
1569
+
1034
1570
  private async animateCardReflow(
1035
1571
  previousRectsArg: Map<string, DOMRect>,
1036
1572
  generationArg: number,
@@ -1069,13 +1605,42 @@ export class DeesHarnessSessionList extends DeesElement {
1069
1605
  this.reflowAnimations.clear();
1070
1606
  }
1071
1607
 
1072
- private clearDragState(): void {
1608
+ private finishPointerDrag(commitArg: boolean): void {
1609
+ const gesture = this.pointerDragGesture;
1610
+ if (!gesture) return;
1611
+ const detail = commitArg && gesture.phase === 'active' && this.dropSlot
1612
+ ? this.createMoveDetail(gesture.sessionId, this.dropSlot.groupId, this.dropSlot.index)
1613
+ : undefined;
1614
+ this.pointerDragGesture = undefined;
1615
+ window.removeEventListener('keydown', this.handleDragKeyDown, true);
1616
+ window.removeEventListener('blur', this.handleDragWindowBlur);
1617
+ this.stopDragAutoScroll();
1073
1618
  this.reflowGeneration += 1;
1074
1619
  this.cancelReflowAnimations();
1620
+ if (gesture.source.matches(':popover-open')) gesture.source.hidePopover();
1621
+ gesture.source.removeAttribute('popover');
1622
+ gesture.source.classList.remove('dragging');
1623
+ for (const property of [
1624
+ '--drag-preview-width',
1625
+ '--drag-preview-height',
1626
+ '--drag-preview-x',
1627
+ '--drag-preview-y',
1628
+ ]) gesture.source.style.removeProperty(property);
1075
1629
  this.draggingSessionId = '';
1076
1630
  this.draggedItemHeight = 0;
1077
1631
  this.dropSlot = undefined;
1078
- this.dragSourceElement = undefined;
1632
+ try {
1633
+ if (gesture.handle.hasPointerCapture(gesture.pointerId)) {
1634
+ gesture.handle.releasePointerCapture(gesture.pointerId);
1635
+ }
1636
+ } catch {
1637
+ // Capture may already be released or the handle may have disconnected.
1638
+ }
1639
+ if (detail) {
1640
+ this.dispatchEvent(
1641
+ new CustomEvent('harness-session-move', { detail, bubbles: true, composed: true }),
1642
+ );
1643
+ }
1079
1644
  }
1080
1645
 
1081
1646
  private emit(name: string, session: IHarnessSessionMeta): void {