@design.estate/dees-catalog 10.0.0 → 11.0.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.
@@ -13,15 +13,19 @@ import {
13
13
  import { themeDefaultStyles } from '../../00theme.js';
14
14
  import { harnessActivityStyles } from '../harness.styles.js';
15
15
  import type {
16
+ IDropSlot,
16
17
  IHarnessGroupContextDetail,
18
+ IHarnessItemMoveDetail,
17
19
  IHarnessResourceAssociation,
18
20
  IHarnessResourceContextDetail,
19
21
  IHarnessResourceEventDetail,
20
22
  IHarnessResourceMeta,
21
23
  IHarnessSessionContextDetail,
24
+ IHarnessSessionEventDetail,
22
25
  IHarnessSessionGroup,
26
+ IHarnessSessionListItemRef,
23
27
  IHarnessSessionMeta,
24
- IHarnessSessionMoveDetail,
28
+ THarnessItemMoveSource,
25
29
  THarnessResourceAssociationInteractionSource,
26
30
  THarnessResourceAssociationRequestDetail,
27
31
  THarnessResourceContextSource,
@@ -39,17 +43,47 @@ declare global {
39
43
  }
40
44
  }
41
45
 
42
- interface IDropSlot {
43
- groupId: string | null;
44
- index: number;
45
- }
46
+ /** One ordered row. Sessions and resources are peers in a single order. */
47
+ type TListItem =
48
+ | {
49
+ kind: 'session';
50
+ key: string;
51
+ id: string;
52
+ title: string;
53
+ sortable: boolean;
54
+ timestamp: number;
55
+ session: IHarnessSessionMeta;
56
+ }
57
+ | {
58
+ kind: 'resource';
59
+ key: string;
60
+ id: string;
61
+ title: string;
62
+ sortable: boolean;
63
+ timestamp: number;
64
+ resource: IHarnessResourceMeta;
65
+ };
66
+
67
+ const itemKey = (refArg: IHarnessSessionListItemRef): string => `${refArg.kind}:${refArg.id}`;
68
+
69
+ const itemRef = (itemArg: IHarnessSessionListItemRef): IHarnessSessionListItemRef => ({
70
+ kind: itemArg.kind,
71
+ id: itemArg.id,
72
+ });
73
+
74
+ const sameItem = (
75
+ leftArg: IHarnessSessionListItemRef | undefined | null,
76
+ rightArg: IHarnessSessionListItemRef | undefined | null,
77
+ ): boolean => !!leftArg
78
+ && !!rightArg
79
+ && leftArg.kind === rightArg.kind
80
+ && leftArg.id === rightArg.id;
46
81
 
47
82
  interface IPointerDragGesture {
48
- type: 'session';
49
83
  pointerId: number;
50
84
  handle: HTMLElement;
51
85
  source: HTMLElement;
52
- sessionId: string;
86
+ item: IHarnessSessionListItemRef;
53
87
  sourceGroupId: string | null;
54
88
  sourceLogicalIndex: number;
55
89
  startClientX: number;
@@ -59,38 +93,21 @@ interface IPointerDragGesture {
59
93
  phase: 'pending' | 'active';
60
94
  }
61
95
 
62
- interface IResourcePointerDragGesture {
63
- type: 'resource';
64
- pointerId: number;
65
- handle: HTMLElement;
66
- source: HTMLElement;
67
- resourceId: string;
68
- fromSessionId: string | null;
69
- eligibleSessionIds: ReadonlySet<string>;
70
- startClientX: number;
71
- startClientY: number;
72
- grabOffsetX: number;
73
- grabOffsetY: number;
74
- phase: 'pending' | 'active';
75
- }
76
-
77
- type TPointerDragGesture = IPointerDragGesture | IResourcePointerDragGesture;
78
-
79
96
  interface IPendingResourceAssociationRequest {
80
97
  resourceId: string;
81
98
  resourceTitle: string;
82
- fromSessionId: string | null;
83
- toSessionId: string | null;
99
+ fromTarget: IHarnessSessionListItemRef | null;
100
+ toTarget: IHarnessSessionListItemRef | null;
84
101
  intent: THarnessResourceAssociationRequestDetail['intent'];
85
102
  }
86
103
 
87
104
  interface IKeyboardMoveFocus {
88
- sessionId: string;
105
+ item: IHarnessSessionListItemRef;
89
106
  groupId: string | null;
90
107
  }
91
108
 
92
109
  type TSectionRenderEntry =
93
- | { type: 'session'; key: string; session: IHarnessSessionMeta; logicalIndex: number }
110
+ | { type: 'item'; key: string; item: TListItem; logicalIndex: number }
94
111
  | { type: 'placeholder'; key: string };
95
112
 
96
113
  /**
@@ -99,16 +116,22 @@ type TSectionRenderEntry =
99
116
  * double-click or Enter, and `harness-session-context` with viewport
100
117
  * coordinates on right-click, Actions, or ContextMenu/Shift+F10.
101
118
  *
102
- * With `enableGrouping`, sessions render under user-defined `groups` and can
103
- * be dragged between groups and reordered. The component never mutates its
104
- * inputs: drops emit `harness-session-move`, the "new group" button emits
105
- * `harness-group-create-request`, and right-clicking a group header emits
106
- * `harness-group-context` — the host persists changes and passes fresh
107
- * `groups` back in. Sessions in no group render in the trailing ungrouped
108
- * section with pinned entries first, then explicit order, then new sessions by
109
- * recency. While a search query is active the list is flat. Generic resources
110
- * project as controlled child rows or into a trailing unattached Resources
111
- * section; association interactions only emit host requests.
119
+ * Sessions and host resources are peers in ONE ordered list: a resource row
120
+ * can be dragged anywhere a session can, including between sessions and into
121
+ * or out of groups. Attaching a resource to a session is a separate explicit
122
+ * host action (`requestResourceAssociation()` /
123
+ * `harness-resource-association-request`) that never moves a row; the
124
+ * association only renders as a marker on the resource row.
125
+ *
126
+ * The component never mutates its inputs: reordering emits `harness-item-move`,
127
+ * the "new group" button emits `harness-group-create-request`, and
128
+ * right-clicking a group header emits `harness-group-context` — the host
129
+ * persists changes and passes fresh `groups` / `ungroupedItemIds` back in.
130
+ * Items in no group render in the trailing ungrouped section with pinned
131
+ * entries first, then explicit order, then new entries by recency. With
132
+ * `enableGrouping` the list additionally renders group chrome. While a search
133
+ * query is active the list is one flat, non-sortable, recency-ordered section
134
+ * in which sessions and resources match as peers.
112
135
  */
113
136
  @customElement('dees-harness-session-list')
114
137
  export class DeesHarnessSessionList extends DeesElement {
@@ -133,9 +156,9 @@ export class DeesHarnessSessionList extends DeesElement {
133
156
  @property({ type: Boolean })
134
157
  accessor enableGrouping: boolean = false;
135
158
 
136
- /** Explicit order for sortable sessions outside user-defined groups. */
159
+ /** Explicit order for sortable items of both kinds outside user-defined groups. */
137
160
  @property({ attribute: false })
138
- accessor ungroupedSessionIds: string[] = [];
161
+ accessor ungroupedItemIds: IHarnessSessionListItemRef[] = [];
139
162
 
140
163
  @property({ attribute: false })
141
164
  accessor resources: IHarnessResourceMeta[] = [];
@@ -146,21 +169,18 @@ export class DeesHarnessSessionList extends DeesElement {
146
169
  @property()
147
170
  accessor selectedResourceId = '';
148
171
 
149
- /** Serializable attach/reassign targets. A missing resource entry means no eligible targets. */
172
+ /**
173
+ * Serializable attach/reassign targets for host menus. A missing entry leaves
174
+ * the resource unrestricted; an empty array means no eligible target.
175
+ */
150
176
  @property({ attribute: false })
151
- accessor eligibleSessionIdsByResourceId: Record<string, string[]> = {};
177
+ accessor eligibleTargetsByResourceId: Record<string, IHarnessSessionListItemRef[]> = {};
152
178
 
153
179
  @state()
154
180
  accessor search: string = '';
155
181
 
156
182
  @state()
157
- accessor draggingSessionId: string = '';
158
-
159
- @state()
160
- accessor draggingResourceId = '';
161
-
162
- @state()
163
- accessor resourceDropTargetSessionId = '';
183
+ accessor draggingItem: IHarnessSessionListItemRef | undefined = undefined;
164
184
 
165
185
  @state()
166
186
  accessor dropSlot: IDropSlot | undefined = undefined;
@@ -188,7 +208,7 @@ export class DeesHarnessSessionList extends DeesElement {
188
208
 
189
209
  private reflowGeneration = 0;
190
210
  private readonly reflowAnimations = new Set<Animation>();
191
- private pointerDragGesture: TPointerDragGesture | undefined;
211
+ private pointerDragGesture: IPointerDragGesture | undefined;
192
212
  private pendingResourceAssociationRequest: IPendingResourceAssociationRequest | undefined;
193
213
  private resourceContextFocusTarget: HTMLElement | undefined;
194
214
  private resourceContextFocusClass = '';
@@ -367,7 +387,9 @@ export class DeesHarnessSessionList extends DeesElement {
367
387
  border-color var(--dees-transition-fast) var(--dees-ease-standard);
368
388
  }
369
389
 
370
- .item.dragging {
390
+ /* One drag preview for both item kinds. */
391
+ .item.dragging,
392
+ .resourceRow.dragging {
371
393
  position: fixed;
372
394
  inset: 0 auto auto 0;
373
395
  width: var(--drag-preview-width);
@@ -410,12 +432,6 @@ export class DeesHarnessSessionList extends DeesElement {
410
432
  box-shadow: inset 3px 0 var(--card-accent);
411
433
  }
412
434
 
413
- .item.resourceDropTarget {
414
- outline: 2px solid var(--dees-color-accent-primary);
415
- outline-offset: 2px;
416
- box-shadow: 0 0 0 5px var(--dees-color-focus-ring), var(--dees-shadow-lg);
417
- }
418
-
419
435
  .itemHeader {
420
436
  display: grid;
421
437
  grid-template-columns: minmax(0, 1fr) 30px 30px;
@@ -592,75 +608,31 @@ export class DeesHarnessSessionList extends DeesElement {
592
608
  font-size: var(--dees-font-caption2-size, 10px);
593
609
  }
594
610
 
595
- .resourceDropLabel {
596
- margin: 0 10px 10px;
597
- padding: 6px 9px;
598
- border-radius: var(--dees-radius-md);
599
- background: color-mix(in srgb, var(--dees-color-accent-primary) 14%, transparent);
600
- color: var(--dees-color-accent-primary);
601
- font-size: var(--dees-font-caption1-size, 11px);
602
- font-weight: 680;
603
- text-align: center;
604
- }
605
-
606
- .resourceChildren {
607
- display: grid;
608
- gap: 4px;
609
- margin: 0 8px 8px 24px;
610
- padding: 0;
611
- list-style: none;
612
- }
613
-
614
- .resourceSection {
615
- margin-top: var(--dees-spacing-md);
616
- }
617
-
618
- .resourceSectionTitle {
619
- margin: 0 0 7px;
620
- padding: var(--dees-spacing-xs) 6px;
621
- color: var(--dees-color-text-secondary);
622
- font-size: var(--dees-font-caption1-size, 11px);
623
- font-weight: 600;
624
- }
625
-
626
- .resourceList {
627
- display: grid;
628
- gap: var(--dees-spacing-sm);
629
- margin: 0;
630
- padding: 0;
631
- list-style: none;
632
- }
633
-
611
+ /* Resource rows are peers of session cards in the same ordered section. */
634
612
  .resourceRow {
613
+ position: relative;
635
614
  display: grid;
636
- grid-template-columns: 26px minmax(0, 1fr) 38px;
615
+ grid-template-columns: minmax(0, 1fr) 38px;
637
616
  min-width: 0;
638
617
  overflow: hidden;
639
618
  border: 1px solid var(--dees-color-border-subtle);
640
619
  border-radius: var(--dees-radius-lg);
620
+ corner-shape: var(--dees-corner-shape);
641
621
  background: color-mix(in srgb, var(--dees-color-bg-secondary) 76%, transparent);
642
622
  color: var(--dees-color-text-primary);
643
623
  box-shadow: var(--dees-shadow-xs);
644
624
  box-sizing: border-box;
645
625
  }
646
626
 
627
+ .resourceRow.draggable {
628
+ grid-template-columns: 26px minmax(0, 1fr) 38px;
629
+ }
630
+
647
631
  .resourceRow.selected {
648
632
  border-color: var(--dees-color-accent-primary);
649
633
  background: color-mix(in srgb, var(--dees-color-accent-primary) 10%, var(--dees-color-bg-secondary));
650
634
  }
651
635
 
652
- .resourceRow.resourceDragging {
653
- position: fixed;
654
- inset: 0 auto auto 0;
655
- width: var(--resource-drag-preview-width);
656
- max-width: none;
657
- margin: 0;
658
- opacity: 0.96;
659
- pointer-events: none;
660
- transform: translate3d(var(--resource-drag-preview-x), var(--resource-drag-preview-y), 0);
661
- box-shadow: var(--dees-shadow-xl);
662
- }
663
-
664
636
  .resourceDragHandle,
665
637
  .resourcePrimary,
666
638
  .resourceActions {
@@ -672,22 +644,7 @@ export class DeesHarnessSessionList extends DeesElement {
672
644
  }
673
645
 
674
646
  .resourceDragHandle {
675
- display: grid;
676
- place-items: center;
677
647
  min-width: 26px;
678
- padding: 0;
679
- color: var(--dees-color-text-muted);
680
- cursor: grab;
681
- touch-action: none;
682
- }
683
-
684
- .resourceDragHandle:disabled {
685
- opacity: 0.35;
686
- cursor: default;
687
- }
688
-
689
- .resourceDragHandle:not(:disabled):active {
690
- cursor: grabbing;
691
648
  }
692
649
 
693
650
  .resourcePrimary {
@@ -741,6 +698,29 @@ export class DeesHarnessSessionList extends DeesElement {
741
698
  white-space: nowrap;
742
699
  }
743
700
 
701
+ /* Decorative marker for the controlled association; never interactive. */
702
+ .resourceAttachment {
703
+ display: flex;
704
+ min-width: 0;
705
+ align-items: center;
706
+ gap: 4px;
707
+ margin-top: 2px;
708
+ color: var(--dees-color-link);
709
+ font-size: var(--dees-font-caption2-size, 10px);
710
+ font-weight: 600;
711
+ }
712
+
713
+ .resourceAttachment dees-icon {
714
+ flex: 0 0 auto;
715
+ }
716
+
717
+ .resourceAttachmentName {
718
+ min-width: 0;
719
+ overflow: hidden;
720
+ text-overflow: ellipsis;
721
+ white-space: nowrap;
722
+ }
723
+
744
724
  .resourceActions {
745
725
  display: grid;
746
726
  place-items: center;
@@ -778,7 +758,7 @@ export class DeesHarnessSessionList extends DeesElement {
778
758
  .emptyDropZone {
779
759
  display: grid;
780
760
  place-items: center;
781
- min-height: 46px;
761
+ overflow: hidden;
782
762
  border: 1px dashed color-mix(in srgb, var(--dees-color-accent-primary) 68%, transparent);
783
763
  border-radius: var(--dees-radius-xl);
784
764
  background: color-mix(in srgb, var(--dees-color-accent-primary) 8%, transparent);
@@ -786,9 +766,14 @@ export class DeesHarnessSessionList extends DeesElement {
786
766
  box-sizing: border-box;
787
767
  }
788
768
 
769
+ /* The placeholder matches the dragged row exactly; only the empty zone has a floor. */
770
+ .emptyDropZone {
771
+ min-height: 46px;
772
+ }
773
+
789
774
  .dropPlaceholder::after,
790
775
  .emptyDropZone::after {
791
- content: 'Drop conversation here';
776
+ content: 'Drop here';
792
777
  font-size: var(--dees-font-caption2-size, 10px);
793
778
  font-weight: 650;
794
779
  letter-spacing: 0.02em;
@@ -931,6 +916,10 @@ export class DeesHarnessSessionList extends DeesElement {
931
916
  .expandToggle { margin: 0 3px 0 0; }
932
917
 
933
918
  .resourceRow {
919
+ grid-template-columns: minmax(0, 1fr) 44px;
920
+ }
921
+
922
+ .resourceRow.draggable {
934
923
  grid-template-columns: 44px minmax(0, 1fr) 44px;
935
924
  }
936
925
 
@@ -947,10 +936,6 @@ export class DeesHarnessSessionList extends DeesElement {
947
936
  }
948
937
 
949
938
  @media (max-width: 280px) {
950
- .resourceChildren {
951
- margin-left: 8px;
952
- }
953
-
954
939
  .resourcePreview {
955
940
  display: none;
956
941
  }
@@ -958,10 +943,6 @@ export class DeesHarnessSessionList extends DeesElement {
958
943
  `,
959
944
  ];
960
945
 
961
- private get sessionsById(): Map<string, IHarnessSessionMeta> {
962
- return new Map(this.sessions.map((session) => [session.id, session]));
963
- }
964
-
965
946
  private get resourcesById(): Map<string, IHarnessResourceMeta> {
966
947
  const result = new Map<string, IHarnessResourceMeta>();
967
948
  for (const resource of this.resources) {
@@ -971,18 +952,23 @@ export class DeesHarnessSessionList extends DeesElement {
971
952
  return result;
972
953
  }
973
954
 
974
- /** First valid record wins; unknown and duplicate records remain untouched but are not projected. */
975
- private get acceptedResourceAssociations(): Map<string, string> {
955
+ /**
956
+ * First valid record wins; unknown records, self-references and duplicates
957
+ * remain untouched but are not projected. Keyed by resource id.
958
+ */
959
+ private get acceptedResourceAssociations(): Map<string, IHarnessSessionListItemRef> {
976
960
  const resourcesById = this.resourcesById;
977
- const sessionsById = this.sessionsById;
978
- const result = new Map<string, string>();
961
+ const itemsByKey = this.itemsByKey;
962
+ const result = new Map<string, IHarnessSessionListItemRef>();
979
963
  for (const association of this.resourceAssociations) {
980
964
  if (
981
- !resourcesById.has(association.resourceId)
982
- || !sessionsById.has(association.sessionId)
965
+ !association.target
966
+ || !resourcesById.has(association.resourceId)
967
+ || !itemsByKey.has(itemKey(association.target))
968
+ || sameItem(association.target, { kind: 'resource', id: association.resourceId })
983
969
  || result.has(association.resourceId)
984
970
  ) continue;
985
- result.set(association.resourceId, association.sessionId);
971
+ result.set(association.resourceId, itemRef(association.target));
986
972
  }
987
973
  return result;
988
974
  }
@@ -991,6 +977,92 @@ export class DeesHarnessSessionList extends DeesElement {
991
977
  return [...this.resourcesById.values()];
992
978
  }
993
979
 
980
+ /** Sessions and resources as one pool of orderable items, before grouping and search. */
981
+ private get acceptedItems(): TListItem[] {
982
+ const result: TListItem[] = [];
983
+ const seenSessionIds = new Set<string>();
984
+ for (const session of this.sessions) {
985
+ if (!session.id || seenSessionIds.has(session.id)) continue;
986
+ seenSessionIds.add(session.id);
987
+ result.push({
988
+ kind: 'session',
989
+ key: `session:${session.id}`,
990
+ id: session.id,
991
+ title: session.title || session.id,
992
+ sortable: session.draggable !== false,
993
+ timestamp: session.updatedAt ?? session.createdAt ?? 0,
994
+ session,
995
+ });
996
+ }
997
+ for (const resource of this.resourcesById.values()) {
998
+ result.push({
999
+ kind: 'resource',
1000
+ key: `resource:${resource.id}`,
1001
+ id: resource.id,
1002
+ title: resource.title || resource.id,
1003
+ sortable: resource.draggable !== false,
1004
+ timestamp: resource.updatedAt ?? resource.createdAt ?? 0,
1005
+ resource,
1006
+ });
1007
+ }
1008
+ return result;
1009
+ }
1010
+
1011
+ private get itemsByKey(): Map<string, TListItem> {
1012
+ return new Map(this.acceptedItems.map((item) => [item.key, item]));
1013
+ }
1014
+
1015
+ /** Groups only organize the list while grouping is enabled. */
1016
+ private get effectiveGroups(): IHarnessSessionGroup[] {
1017
+ return this.enableGrouping ? this.groups : [];
1018
+ }
1019
+
1020
+ private get groupedItemKeys(): Set<string> {
1021
+ const keys = new Set<string>();
1022
+ for (const group of this.effectiveGroups) {
1023
+ for (const ref of group.itemIds) keys.add(itemKey(ref));
1024
+ }
1025
+ return keys;
1026
+ }
1027
+
1028
+ private itemsForGroup(groupIdArg: string | null): TListItem[] {
1029
+ if (groupIdArg === null) return this.ungroupedItems;
1030
+ const group = this.effectiveGroups.find((candidate) => candidate.id === groupIdArg);
1031
+ if (!group) return [];
1032
+ const byKey = this.itemsByKey;
1033
+ const seen = new Set<string>();
1034
+ const result: TListItem[] = [];
1035
+ for (const ref of group.itemIds) {
1036
+ const key = itemKey(ref);
1037
+ const item = byKey.get(key);
1038
+ if (!item || seen.has(key)) continue;
1039
+ seen.add(key);
1040
+ result.push(item);
1041
+ }
1042
+ return result;
1043
+ }
1044
+
1045
+ /** Pinned entries first, then explicitly ordered items, then unknown items by recency. */
1046
+ private get ungroupedItems(): TListItem[] {
1047
+ const grouped = this.groupedItemKeys;
1048
+ const available = this.acceptedItems.filter((item) => !grouped.has(item.key));
1049
+ const pinned = available.filter((item) => !item.sortable);
1050
+ const sortableByKey = new Map(
1051
+ available.filter((item) => item.sortable).map((item) => [item.key, item]),
1052
+ );
1053
+ const ordered: TListItem[] = [];
1054
+ for (const ref of this.ungroupedItemIds) {
1055
+ const item = sortableByKey.get(itemKey(ref));
1056
+ if (!item) continue;
1057
+ ordered.push(item);
1058
+ sortableByKey.delete(item.key);
1059
+ }
1060
+ const unknown = [...sortableByKey.values()].sort(
1061
+ (left, right) => right.timestamp - left.timestamp,
1062
+ );
1063
+ return [...pinned, ...ordered, ...unknown];
1064
+ }
1065
+
994
1066
  private resourceMatchesSearch(resourceArg: IHarnessResourceMeta, queryArg: string): boolean {
995
1067
  return [resourceArg.id, resourceArg.kind, resourceArg.title, resourceArg.preview]
996
1068
  .filter(Boolean)
@@ -1008,71 +1080,45 @@ export class DeesHarnessSessionList extends DeesElement {
1008
1080
  .includes(queryArg);
1009
1081
  }
1010
1082
 
1011
- private resourcesForSession(sessionArg: IHarnessSessionMeta): IHarnessResourceMeta[] {
1012
- const associations = this.acceptedResourceAssociations;
1013
- const associated = this.acceptedResources.filter(
1014
- (resource) => associations.get(resource.id) === sessionArg.id,
1015
- );
1016
- const query = this.search.trim().toLowerCase();
1017
- if (!query || this.sessionMatchesSearch(sessionArg, query)) return associated;
1018
- return associated.filter((resource) => this.resourceMatchesSearch(resource, query));
1083
+ private itemMatchesSearch(itemArg: TListItem, queryArg: string): boolean {
1084
+ return itemArg.kind === 'session'
1085
+ ? this.sessionMatchesSearch(itemArg.session, queryArg)
1086
+ : this.resourceMatchesSearch(itemArg.resource, queryArg);
1019
1087
  }
1020
1088
 
1021
- private get unattachedResources(): IHarnessResourceMeta[] {
1022
- const associations = this.acceptedResourceAssociations;
1089
+ /** Search flattens the list: sessions and resources match as peers, newest first. */
1090
+ private get searchItems(): TListItem[] {
1023
1091
  const query = this.search.trim().toLowerCase();
1024
- return this.acceptedResources.filter((resource) => (
1025
- !associations.has(resource.id)
1026
- && (!query || this.resourceMatchesSearch(resource, query))
1027
- ));
1092
+ const sorted = [...this.acceptedItems].sort(
1093
+ (left, right) => right.timestamp - left.timestamp,
1094
+ );
1095
+ if (!query) return sorted;
1096
+ return sorted.filter((item) => this.itemMatchesSearch(item, query));
1028
1097
  }
1029
1098
 
1030
- private get groupedSessionIds(): Set<string> {
1031
- const ids = new Set<string>();
1032
- for (const group of this.groups) {
1033
- for (const id of group.sessionIds) ids.add(id);
1034
- }
1035
- return ids;
1099
+ private get searchActive(): boolean {
1100
+ return this.search.trim().length > 0;
1036
1101
  }
1037
1102
 
1038
- /** Pinned entries first, followed by explicitly ordered sortable sessions. */
1039
- private get ungroupedSessions(): IHarnessSessionMeta[] {
1040
- const grouped = this.groupedSessionIds;
1041
- const available = this.sessions.filter((session) => !grouped.has(session.id));
1042
- const pinned = available.filter((session) => session.draggable === false);
1043
- const sortableById = new Map(
1044
- available.filter((session) => session.draggable !== false).map((session) => [session.id, session]),
1045
- );
1046
- const ordered: IHarnessSessionMeta[] = [];
1047
- for (const sessionId of this.ungroupedSessionIds) {
1048
- const session = sortableById.get(sessionId);
1049
- if (!session) continue;
1050
- ordered.push(session);
1051
- sortableById.delete(sessionId);
1052
- }
1053
- const newSessions = [...sortableById.values()].sort(
1054
- (left, right) =>
1055
- (right.updatedAt ?? right.createdAt ?? 0) - (left.updatedAt ?? left.createdAt ?? 0),
1056
- );
1057
- return [...pinned, ...ordered, ...newSessions];
1103
+ private sortableItems(itemsArg: TListItem[], excludeDraggingArg = true): TListItem[] {
1104
+ return itemsArg.filter((item) => (
1105
+ item.sortable
1106
+ && (!excludeDraggingArg || !sameItem(this.draggingItem, item))
1107
+ ));
1058
1108
  }
1059
1109
 
1060
- private get filteredSessions(): IHarnessSessionMeta[] {
1061
- const query = this.search.trim().toLowerCase();
1062
- const associations = this.acceptedResourceAssociations;
1063
- const matchingAttachedSessionIds = new Set(
1064
- this.acceptedResources
1065
- .filter((resource) => query && this.resourceMatchesSearch(resource, query))
1066
- .map((resource) => associations.get(resource.id))
1067
- .filter((sessionId): sessionId is string => sessionId !== undefined),
1068
- );
1069
- const sorted = [...this.sessions].sort(
1070
- (left, right) => (right.updatedAt ?? right.createdAt ?? 0) - (left.updatedAt ?? left.createdAt ?? 0),
1071
- );
1072
- if (!query) return sorted;
1073
- return sorted.filter(
1074
- (session) => this.sessionMatchesSearch(session, query) || matchingAttachedSessionIds.has(session.id),
1075
- );
1110
+ private groupIdForItem(refArg: IHarnessSessionListItemRef): string | null {
1111
+ return this.effectiveGroups.find(
1112
+ (group) => group.itemIds.some((candidate) => sameItem(candidate, refArg)),
1113
+ )?.id ?? null;
1114
+ }
1115
+
1116
+ private rowElementFor(refArg: IHarnessSessionListItemRef): HTMLElement | undefined {
1117
+ return Array.from(
1118
+ this.shadowRoot?.querySelectorAll<HTMLElement>('[data-item-kind][data-item-id]') ?? [],
1119
+ ).find((candidate) => (
1120
+ candidate.dataset.itemKind === refArg.kind && candidate.dataset.itemId === refArg.id
1121
+ ));
1076
1122
  }
1077
1123
 
1078
1124
  public focusSearch(): void {
@@ -1081,46 +1127,51 @@ export class DeesHarnessSessionList extends DeesElement {
1081
1127
 
1082
1128
  public requestResourceAssociation(
1083
1129
  resourceIdArg: string,
1084
- toSessionIdArg: string | null,
1130
+ toTargetArg: IHarnessSessionListItemRef | null,
1085
1131
  sourceArg: THarnessResourceAssociationInteractionSource = 'action',
1086
1132
  ): boolean {
1087
1133
  const resource = this.resourcesById.get(resourceIdArg);
1088
1134
  if (!resource) return false;
1089
- const fromSessionId = this.acceptedResourceAssociations.get(resourceIdArg) ?? null;
1090
- if (fromSessionId === toSessionIdArg) return false;
1135
+ const fromTarget = this.acceptedResourceAssociations.get(resourceIdArg) ?? null;
1136
+ if (sameItem(fromTarget, toTargetArg)) return false;
1091
1137
  if (
1092
- toSessionIdArg !== null
1093
- && (!this.sessionsById.has(toSessionIdArg) || !this.isResourceTargetEligible(resourceIdArg, toSessionIdArg))
1138
+ toTargetArg !== null
1139
+ && (
1140
+ !this.itemsByKey.has(itemKey(toTargetArg))
1141
+ || sameItem(toTargetArg, { kind: 'resource', id: resourceIdArg })
1142
+ || !this.isResourceTargetEligible(resourceIdArg, toTargetArg)
1143
+ )
1094
1144
  ) return false;
1095
- if (toSessionIdArg === null && fromSessionId === null) return false;
1096
- const detail: THarnessResourceAssociationRequestDetail = fromSessionId === null
1145
+ if (toTargetArg === null && fromTarget === null) return false;
1146
+ const toTarget = toTargetArg === null ? null : itemRef(toTargetArg);
1147
+ const detail: THarnessResourceAssociationRequestDetail = fromTarget === null
1097
1148
  ? {
1098
1149
  intent: 'attach',
1099
1150
  resource,
1100
- fromSessionId: null,
1101
- toSessionId: toSessionIdArg!,
1151
+ fromTarget: null,
1152
+ toTarget: toTarget!,
1102
1153
  source: sourceArg,
1103
1154
  }
1104
- : toSessionIdArg === null
1155
+ : toTarget === null
1105
1156
  ? {
1106
1157
  intent: 'detach',
1107
1158
  resource,
1108
- fromSessionId,
1109
- toSessionId: null,
1159
+ fromTarget,
1160
+ toTarget: null,
1110
1161
  source: sourceArg,
1111
1162
  }
1112
1163
  : {
1113
1164
  intent: 'reassign',
1114
1165
  resource,
1115
- fromSessionId,
1116
- toSessionId: toSessionIdArg,
1166
+ fromTarget,
1167
+ toTarget,
1117
1168
  source: sourceArg,
1118
1169
  };
1119
1170
  this.pendingResourceAssociationRequest = {
1120
1171
  resourceId: resource.id,
1121
1172
  resourceTitle: resource.title || resource.id,
1122
- fromSessionId,
1123
- toSessionId: toSessionIdArg,
1173
+ fromTarget,
1174
+ toTarget,
1124
1175
  intent: detail.intent,
1125
1176
  };
1126
1177
  this.moveAnnouncement = this.resourceAssociationAnnouncement(detail, false);
@@ -1147,12 +1198,6 @@ export class DeesHarnessSessionList extends DeesElement {
1147
1198
  }
1148
1199
  if (changedProperties.has('sessions')) {
1149
1200
  const knownIds = new Set(this.sessions.map((session) => session.id));
1150
- if (
1151
- this.pointerDragGesture?.type === 'session'
1152
- && !knownIds.has(this.pointerDragGesture.sessionId)
1153
- ) {
1154
- this.finishPointerDrag(false);
1155
- }
1156
1201
  const nextExpanded = new Set(
1157
1202
  [...this.expandedSessionIds].filter((sessionId) => knownIds.has(sessionId)),
1158
1203
  );
@@ -1164,13 +1209,6 @@ export class DeesHarnessSessionList extends DeesElement {
1164
1209
  }
1165
1210
  }
1166
1211
  if (changedProperties.has('resources') || changedProperties.has('resourceAssociations')) {
1167
- const gesture = this.pointerDragGesture;
1168
- if (gesture?.type === 'resource') {
1169
- const currentSessionId = this.acceptedResourceAssociations.get(gesture.resourceId) ?? null;
1170
- if (!this.resourcesById.has(gesture.resourceId) || currentSessionId !== gesture.fromSessionId) {
1171
- this.finishPointerDrag(false);
1172
- }
1173
- }
1174
1212
  if (
1175
1213
  this.resourceContextResourceId
1176
1214
  && !this.resourcesById.has(this.resourceContextResourceId)
@@ -1179,16 +1217,15 @@ export class DeesHarnessSessionList extends DeesElement {
1179
1217
  }
1180
1218
  if (
1181
1219
  changedProperties.has('sessions')
1182
- || changedProperties.has('eligibleSessionIdsByResourceId')
1220
+ || changedProperties.has('resources')
1221
+ || changedProperties.has('search')
1183
1222
  ) {
1223
+ // A drag is positional only: it survives association and eligibility
1224
+ // updates, but never outlives its own row.
1184
1225
  const gesture = this.pointerDragGesture;
1185
- if (
1186
- gesture?.type === 'resource'
1187
- && !this.sameSessionIds(
1188
- gesture.eligibleSessionIds,
1189
- new Set(this.eligibleSessionIds(gesture.resourceId)),
1190
- )
1191
- ) this.finishPointerDrag(false);
1226
+ if (gesture && (this.searchActive || !this.itemsByKey.has(itemKey(gesture.item)))) {
1227
+ this.finishPointerDrag(false);
1228
+ }
1192
1229
  }
1193
1230
  }
1194
1231
 
@@ -1211,7 +1248,6 @@ export class DeesHarnessSessionList extends DeesElement {
1211
1248
  }
1212
1249
 
1213
1250
  public render(): TemplateResult {
1214
- const groupingActive = this.enableGrouping && !this.search.trim();
1215
1251
  return html`
1216
1252
  <div class="searchRow">
1217
1253
  <div class="searchField">
@@ -1249,15 +1285,15 @@ export class DeesHarnessSessionList extends DeesElement {
1249
1285
  <div class="listFrame">
1250
1286
  <div class="scrollShadow top ${this.topScrollShadowVisible ? 'visible' : ''}"></div>
1251
1287
  <div
1252
- class="list ${this.draggingSessionId || this.draggingResourceId ? 'dragActive' : ''}"
1288
+ class="list ${this.draggingItem ? 'dragActive' : ''}"
1253
1289
  @scroll=${this.syncScrollShadowState}
1254
1290
  >
1255
1291
  <div class="listContent">
1256
1292
  ${this.loading
1257
1293
  ? html`<div class="emptyNote">Loading conversations…</div>`
1258
- : groupingActive
1259
- ? this.renderGroupedList()
1260
- : this.renderFlatList()}
1294
+ : this.searchActive
1295
+ ? this.renderSearchList()
1296
+ : this.renderOrderedList()}
1261
1297
  </div>
1262
1298
  </div>
1263
1299
  <div class="scrollShadow bottom ${this.bottomScrollShadowVisible ? 'visible' : ''}"></div>
@@ -1266,84 +1302,33 @@ export class DeesHarnessSessionList extends DeesElement {
1266
1302
  `;
1267
1303
  }
1268
1304
 
1269
- private renderFlatList(): TemplateResult {
1270
- const sessions = this.filteredSessions;
1271
- const unattachedResources = this.unattachedResources;
1272
- if (!sessions.length && !unattachedResources.length) {
1273
- return html`<div class="emptyNote">
1274
- ${this.acceptedResources.length
1275
- ? this.sessions.length
1276
- ? 'No conversations or resources match this search.'
1277
- : 'No resources match this search.'
1278
- : this.sessions.length
1279
- ? 'No conversations match this search.'
1280
- : this.emptyText}
1281
- </div>`;
1282
- }
1283
- return html`
1284
- <div class="section">
1285
- ${repeat(
1286
- sessions,
1287
- (session) => session.id,
1288
- (session) => this.renderItem(session, null, -1),
1289
- )}
1290
- </div>
1291
- ${this.renderUnattachedResources(unattachedResources)}
1292
- `;
1305
+ private renderEmptyNote(): TemplateResult {
1306
+ return html`<div class="emptyNote">
1307
+ ${this.acceptedResources.length
1308
+ ? this.sessions.length
1309
+ ? 'No conversations or resources match this search.'
1310
+ : 'No resources match this search.'
1311
+ : this.sessions.length
1312
+ ? 'No conversations match this search.'
1313
+ : this.emptyText}
1314
+ </div>`;
1315
+ }
1316
+
1317
+ private renderSearchList(): TemplateResult {
1318
+ const items = this.searchItems;
1319
+ if (!items.length) return this.renderEmptyNote();
1320
+ return this.renderSection(items, null, false);
1293
1321
  }
1294
1322
 
1295
- private renderGroupedList(): TemplateResult {
1296
- const byId = this.sessionsById;
1297
- const ungrouped = this.ungroupedSessions;
1298
- const unattachedResources = this.unattachedResources;
1299
- if (!this.sessions.length && !this.groups.length && !unattachedResources.length) {
1323
+ private renderOrderedList(): TemplateResult {
1324
+ const groups = this.effectiveGroups;
1325
+ const ungrouped = this.ungroupedItems;
1326
+ if (!this.acceptedItems.length && !groups.length) {
1300
1327
  return html`<div class="emptyNote">${this.emptyText}</div>`;
1301
1328
  }
1302
1329
  return html`
1303
- ${this.groups.map((group) => {
1304
- const members = group.sessionIds
1305
- .map((id) => byId.get(id))
1306
- .filter((session): session is IHarnessSessionMeta => session !== undefined);
1307
- const collapsed = this.collapsedGroupIds.has(group.id);
1308
- const attentionCount = members.filter((session) => session.state === 'attention' || session.state === 'error').length;
1309
- const isHeaderDrop =
1310
- this.dropSlot?.groupId === group.id
1311
- && this.dropSlot.index === this.sortableSessions(members).length
1312
- && collapsed;
1313
- return html`
1314
- <button
1315
- class="groupHeader ${collapsed ? 'collapsed' : ''} ${isHeaderDrop ? 'dropTarget' : ''}"
1316
- data-drop-group-id=${group.id}
1317
- data-drop-index=${this.sortableSessions(members).length}
1318
- data-drop-kind="slot"
1319
- type="button"
1320
- @click=${() => this.toggleGroupCollapsed(group.id)}
1321
- @contextmenu=${(event: MouseEvent) => {
1322
- event.preventDefault();
1323
- const detail: IHarnessGroupContextDetail = {
1324
- group,
1325
- clientX: event.clientX,
1326
- clientY: event.clientY,
1327
- originalEvent: event,
1328
- };
1329
- this.dispatchEvent(
1330
- new CustomEvent('harness-group-context', { detail, bubbles: true, composed: true }),
1331
- );
1332
- }}
1333
- >
1334
- <dees-icon class="chevron" icon="lucide:ChevronDown" iconSize="12"></dees-icon>
1335
- <span>${group.name}</span>
1336
- ${attentionCount ? html`<span class="groupAttention" aria-label=${`${attentionCount} need attention`}>
1337
- <dees-icon aria-hidden="true" icon="lucide:CircleAlert" iconSize="11"></dees-icon>${attentionCount}
1338
- </span>` : ''}
1339
- <span class="groupCount">${members.length}</span>
1340
- </button>
1341
- ${collapsed
1342
- ? ''
1343
- : this.renderSection(members, group.id)}
1344
- `;
1345
- })}
1346
- ${this.groups.length && (ungrouped.length || this.draggingSessionId)
1330
+ ${groups.map((group) => this.renderGroup(group))}
1331
+ ${groups.length && (ungrouped.length || this.draggingItem)
1347
1332
  ? html`
1348
1333
  <button
1349
1334
  class="groupHeader"
@@ -1358,56 +1343,107 @@ export class DeesHarnessSessionList extends DeesElement {
1358
1343
  </button>
1359
1344
  `
1360
1345
  : ''}
1361
- ${this.renderSection(ungrouped, null)}
1362
- ${this.renderUnattachedResources(unattachedResources)}
1346
+ ${this.renderSection(ungrouped, null, true)}
1347
+ `;
1348
+ }
1349
+
1350
+ private renderGroup(groupArg: IHarnessSessionGroup): TemplateResult {
1351
+ const members = this.itemsForGroup(groupArg.id);
1352
+ const collapsed = this.collapsedGroupIds.has(groupArg.id);
1353
+ const attentionCount = members.filter((item) => (
1354
+ item.kind === 'session' && (item.session.state === 'attention' || item.session.state === 'error')
1355
+ )).length;
1356
+ const appendIndex = this.sortableItems(members).length;
1357
+ const isHeaderDrop = this.dropSlot?.groupId === groupArg.id
1358
+ && this.dropSlot.index === appendIndex
1359
+ && collapsed;
1360
+ return html`
1361
+ <button
1362
+ class="groupHeader ${collapsed ? 'collapsed' : ''} ${isHeaderDrop ? 'dropTarget' : ''}"
1363
+ data-drop-group-id=${groupArg.id}
1364
+ data-drop-index=${appendIndex}
1365
+ data-drop-kind="slot"
1366
+ type="button"
1367
+ @click=${() => this.toggleGroupCollapsed(groupArg.id)}
1368
+ @contextmenu=${(event: MouseEvent) => {
1369
+ event.preventDefault();
1370
+ const detail: IHarnessGroupContextDetail = {
1371
+ group: groupArg,
1372
+ clientX: event.clientX,
1373
+ clientY: event.clientY,
1374
+ originalEvent: event,
1375
+ };
1376
+ this.dispatchEvent(
1377
+ new CustomEvent('harness-group-context', { detail, bubbles: true, composed: true }),
1378
+ );
1379
+ }}
1380
+ >
1381
+ <dees-icon class="chevron" icon="lucide:ChevronDown" iconSize="12"></dees-icon>
1382
+ <span>${groupArg.name}</span>
1383
+ ${attentionCount ? html`<span class="groupAttention" aria-label=${`${attentionCount} need attention`}>
1384
+ <dees-icon aria-hidden="true" icon="lucide:CircleAlert" iconSize="11"></dees-icon>${attentionCount}
1385
+ </span>` : ''}
1386
+ <span class="groupCount">${members.length}</span>
1387
+ </button>
1388
+ ${collapsed ? '' : this.renderSection(members, groupArg.id, true)}
1363
1389
  `;
1364
1390
  }
1365
1391
 
1366
1392
  private renderSection(
1367
- sessions: IHarnessSessionMeta[],
1368
- groupId: string | null,
1393
+ itemsArg: TListItem[],
1394
+ groupIdArg: string | null,
1395
+ sortableArg: boolean,
1369
1396
  ): TemplateResult {
1370
- const slot = this.dropSlot;
1397
+ const slot = sortableArg ? this.dropSlot : undefined;
1371
1398
  const entries: TSectionRenderEntry[] = [];
1372
1399
  let logicalIndex = 0;
1373
- for (const session of sessions) {
1374
- const isDragging = session.id === this.draggingSessionId;
1375
- const sortable = session.draggable !== false;
1400
+ for (const item of itemsArg) {
1401
+ const isDragging = sameItem(this.draggingItem, item);
1402
+ const sortable = sortableArg && item.sortable;
1376
1403
  if (
1377
1404
  slot
1378
- && slot.groupId === groupId
1405
+ && slot.groupId === groupIdArg
1379
1406
  && slot.index === logicalIndex
1380
1407
  && !isDragging
1381
1408
  && sortable
1382
1409
  ) {
1383
- entries.push({ type: 'placeholder', key: `placeholder:${groupId ?? 'ungrouped'}` });
1410
+ entries.push({ type: 'placeholder', key: `placeholder:${groupIdArg ?? 'ungrouped'}` });
1384
1411
  }
1385
1412
  entries.push({
1386
- type: 'session',
1387
- key: `session:${session.id}`,
1388
- session,
1413
+ type: 'item',
1414
+ key: item.key,
1415
+ item,
1389
1416
  logicalIndex: sortable ? logicalIndex : -1,
1390
1417
  });
1391
1418
  if (sortable && !isDragging) logicalIndex += 1;
1392
1419
  }
1393
- if (slot && slot.groupId === groupId && slot.index === logicalIndex) {
1394
- entries.push({ type: 'placeholder', key: `placeholder:${groupId ?? 'ungrouped'}` });
1420
+ if (slot && slot.groupId === groupIdArg && slot.index === logicalIndex) {
1421
+ entries.push({ type: 'placeholder', key: `placeholder:${groupIdArg ?? 'ungrouped'}` });
1395
1422
  }
1396
1423
  const hasPlaceholder = entries.some((entry) => entry.type === 'placeholder');
1424
+ // Resolve the controlled associations once per section, not once per row.
1425
+ const associations = this.acceptedResourceAssociations;
1397
1426
  return html`
1398
- <div class="section" data-group-id=${groupId ?? ''}>
1427
+ <div class="section" data-group-id=${groupIdArg ?? ''}>
1399
1428
  ${repeat(
1400
1429
  entries,
1401
1430
  (entry) => entry.key,
1402
1431
  (entry) => entry.type === 'placeholder'
1403
- ? this.renderDropPlaceholder(groupId, slot?.index ?? 0)
1404
- : this.renderItem(entry.session, groupId, entry.logicalIndex),
1432
+ ? this.renderDropPlaceholder(groupIdArg, slot?.index ?? 0)
1433
+ : entry.item.kind === 'session'
1434
+ ? this.renderSessionItem(entry.item.session, groupIdArg, entry.logicalIndex)
1435
+ : this.renderResourceItem(
1436
+ entry.item.resource,
1437
+ groupIdArg,
1438
+ entry.logicalIndex,
1439
+ associations.get(entry.item.id) ?? null,
1440
+ ),
1405
1441
  )}
1406
- ${this.draggingSessionId && !hasPlaceholder && this.sortableSessions(sessions).length === 0
1442
+ ${this.draggingItem && sortableArg && !hasPlaceholder && this.sortableItems(itemsArg).length === 0
1407
1443
  ? html`
1408
1444
  <div
1409
1445
  class="emptyDropZone"
1410
- data-drop-group-id=${groupId ?? ''}
1446
+ data-drop-group-id=${groupIdArg ?? ''}
1411
1447
  data-drop-index="0"
1412
1448
  data-drop-kind="slot"
1413
1449
  ></div>
@@ -1424,46 +1460,72 @@ export class DeesHarnessSessionList extends DeesElement {
1424
1460
  data-drop-group-id=${groupId ?? ''}
1425
1461
  data-drop-index=${index}
1426
1462
  data-drop-kind="slot"
1427
- style=${`height: ${Math.max(46, this.draggedItemHeight)}px`}
1463
+ style=${`height: ${Math.max(24, this.draggedItemHeight)}px`}
1428
1464
  ></div>
1429
1465
  `;
1430
1466
  }
1431
1467
 
1432
- private renderItem(
1468
+ /** One grip for both kinds; only its size and extra class differ. */
1469
+ private renderDragHandle(
1470
+ refArg: IHarnessSessionListItemRef,
1471
+ titleArg: string,
1472
+ groupIdArg: string | null,
1473
+ logicalIndexArg: number,
1474
+ ): TemplateResult {
1475
+ const isResource = refArg.kind === 'resource';
1476
+ return html`
1477
+ <button
1478
+ class="dragHandle ${isResource ? 'resourceDragHandle' : ''}"
1479
+ type="button"
1480
+ title="Drag to reorder; use arrow, Home, or End keys for keyboard reordering"
1481
+ aria-label=${`Reorder ${titleArg}`}
1482
+ aria-keyshortcuts="ArrowUp ArrowDown Home End"
1483
+ @pointerdown=${(event: PointerEvent) => {
1484
+ this.handlePointerDown(event, refArg, groupIdArg, logicalIndexArg);
1485
+ }}
1486
+ @pointermove=${this.handlePointerMove}
1487
+ @pointerup=${this.handlePointerUp}
1488
+ @pointercancel=${this.handlePointerCancel}
1489
+ @lostpointercapture=${this.handleLostPointerCapture}
1490
+ @keydown=${(event: KeyboardEvent) => {
1491
+ this.handleKeyboardMove(event, refArg, titleArg, groupIdArg);
1492
+ }}
1493
+ >
1494
+ <dees-icon icon="lucide:GripVertical" iconSize=${isResource ? '13' : '14'}></dees-icon>
1495
+ </button>
1496
+ `;
1497
+ }
1498
+
1499
+ private renderSessionItem(
1433
1500
  session: IHarnessSessionMeta,
1434
1501
  groupId: string | null,
1435
1502
  index: number,
1436
1503
  ): TemplateResult {
1437
- const draggable = (
1438
- this.enableGrouping
1439
- && session.draggable !== false
1440
- && index >= 0
1441
- );
1504
+ const draggable = index >= 0;
1442
1505
  const expanded = this.expandedSessionIds.has(session.id);
1443
1506
  const state = session.state ?? 'normal';
1444
1507
  const statusLabel = this.sessionStatusLabel(session);
1445
1508
  const active = (state === 'normal' || state === 'working') && (session.working ?? state === 'working');
1446
1509
  const detailsId = `session-details-${session.id}`;
1447
1510
  const dropIndex = index < 0 ? 0 : index;
1448
- const resources = this.resourcesForSession(session);
1449
- const isResourceDropTarget = this.resourceDropTargetSessionId === session.id;
1511
+ const title = session.title || session.id;
1450
1512
  return html`
1451
1513
  <article
1452
1514
  class="item state-${state} ${active ? 'active' : ''} ${expanded ? 'expanded' : ''} ${
1453
1515
  session.id === this.selectedSessionId ? 'selected' : ''
1454
1516
  } ${
1455
- this.draggingSessionId === session.id ? 'dragging' : ''
1456
- } ${
1457
- isResourceDropTarget ? 'resourceDropTarget' : ''
1517
+ sameItem(this.draggingItem, { kind: 'session', id: session.id }) ? 'dragging' : ''
1458
1518
  }"
1459
1519
  data-session-id=${session.id}
1520
+ data-item-kind="session"
1521
+ data-item-id=${session.id}
1460
1522
  data-drop-group-id=${groupId ?? ''}
1461
1523
  data-drop-index=${dropIndex}
1462
1524
  data-drop-kind=${index < 0 ? 'slot' : 'card'}
1463
1525
  @dblclick=${(event: MouseEvent) => {
1464
1526
  if (this.eventTargetsExpandToggle(event) || this.eventTargetsDragHandle(event)
1465
1527
  || this.eventTargetsSessionActions(event)) return;
1466
- this.emit('harness-session-open', session);
1528
+ this.emitSession('harness-session-open', session);
1467
1529
  }}
1468
1530
  @contextmenu=${(event: MouseEvent) => {
1469
1531
  if (this.eventTargetsExpandToggle(event)) return;
@@ -1483,39 +1545,19 @@ export class DeesHarnessSessionList extends DeesElement {
1483
1545
  <div class="itemInner">
1484
1546
  <div class="itemHeader ${draggable ? 'draggable' : ''}">
1485
1547
  ${draggable
1486
- ? html`
1487
- <button
1488
- class="dragHandle"
1489
- type="button"
1490
- title="Drag to reorder; use arrow, Home, or End keys for keyboard reordering"
1491
- aria-label=${`Reorder ${session.title || session.id}`}
1492
- aria-keyshortcuts="ArrowUp ArrowDown Home End"
1493
- @pointerdown=${(event: PointerEvent) => {
1494
- this.handlePointerDown(event, session, groupId, index);
1495
- }}
1496
- @pointermove=${this.handlePointerMove}
1497
- @pointerup=${this.handlePointerUp}
1498
- @pointercancel=${this.handlePointerCancel}
1499
- @lostpointercapture=${this.handleLostPointerCapture}
1500
- @keydown=${(event: KeyboardEvent) => {
1501
- this.handleKeyboardMove(event, session, groupId);
1502
- }}
1503
- >
1504
- <dees-icon icon="lucide:GripVertical" iconSize="14"></dees-icon>
1505
- </button>
1506
- `
1548
+ ? this.renderDragHandle({ kind: 'session', id: session.id }, title, groupId, index)
1507
1549
  : ''}
1508
1550
  <button
1509
1551
  class="itemPrimary"
1510
1552
  type="button"
1511
1553
  aria-keyshortcuts="ContextMenu Shift+F10"
1512
1554
  aria-current=${session.id === this.selectedSessionId ? 'true' : 'false'}
1513
- @click=${() => this.emit('harness-session-select', session)}
1555
+ @click=${() => this.emitSession('harness-session-select', session)}
1514
1556
  @keydown=${(event: KeyboardEvent) => {
1515
1557
  if (this.handleSessionContextKey(event)) return;
1516
1558
  if (event.key !== 'Enter') return;
1517
1559
  event.preventDefault();
1518
- this.emit('harness-session-open', session);
1560
+ this.emitSession('harness-session-open', session);
1519
1561
  }}
1520
1562
  >
1521
1563
  <span class="stateDot" aria-hidden="true">
@@ -1526,7 +1568,7 @@ export class DeesHarnessSessionList extends DeesElement {
1526
1568
  ${session.icon
1527
1569
  ? html`<dees-icon class="itemIcon" icon=${session.icon} iconSize="13"></dees-icon>`
1528
1570
  : ''}
1529
- <span class="titleText">${session.title || session.id}</span>
1571
+ <span class="titleText">${title}</span>
1530
1572
  </span>
1531
1573
  <span class="itemStatus" title=${statusLabel}>${statusLabel}</span>
1532
1574
  <span class="itemIdentity">
@@ -1543,7 +1585,7 @@ export class DeesHarnessSessionList extends DeesElement {
1543
1585
  class="sessionActions"
1544
1586
  type="button"
1545
1587
  title="Actions"
1546
- aria-label=${`Actions for ${session.title || session.id}`}
1588
+ aria-label=${`Actions for ${title}`}
1547
1589
  aria-haspopup="menu"
1548
1590
  aria-keyshortcuts="ContextMenu Shift+F10"
1549
1591
  @click=${this.invokeSessionContext}
@@ -1565,20 +1607,6 @@ export class DeesHarnessSessionList extends DeesElement {
1565
1607
  <dees-icon icon="lucide:ChevronDown" iconSize="15"></dees-icon>
1566
1608
  </button>
1567
1609
  </div>
1568
- ${isResourceDropTarget
1569
- ? html`<div class="resourceDropLabel">Associate resource with ${session.title || session.id}</div>`
1570
- : ''}
1571
- ${resources.length
1572
- ? html`
1573
- <ul class="resourceChildren" aria-label=${`Resources for ${session.title || session.id}`}>
1574
- ${repeat(
1575
- resources,
1576
- (resource) => resource.id,
1577
- (resource) => this.renderResource(resource, session.id),
1578
- )}
1579
- </ul>
1580
- `
1581
- : ''}
1582
1610
  ${expanded
1583
1611
  ? html`
1584
1612
  <div class="itemDetails" id=${detailsId}>
@@ -1594,41 +1622,35 @@ export class DeesHarnessSessionList extends DeesElement {
1594
1622
  `;
1595
1623
  }
1596
1624
 
1597
- private renderUnattachedResources(resourcesArg: IHarnessResourceMeta[]): TemplateResult {
1598
- if (!resourcesArg.length) return html``;
1599
- return html`
1600
- <section class="resourceSection" aria-labelledby="unattached-resources-heading">
1601
- <h2 class="resourceSectionTitle" id="unattached-resources-heading">Resources</h2>
1602
- <ul class="resourceList" aria-label="Unattached resources">
1603
- ${repeat(
1604
- resourcesArg,
1605
- (resource) => resource.id,
1606
- (resource) => this.renderResource(resource, null),
1607
- )}
1608
- </ul>
1609
- </section>
1610
- `;
1611
- }
1612
-
1613
- private renderResource(
1625
+ private renderResourceItem(
1614
1626
  resourceArg: IHarnessResourceMeta,
1615
- sessionIdArg: string | null,
1627
+ groupIdArg: string | null,
1628
+ indexArg: number,
1629
+ attachedTarget: IHarnessSessionListItemRef | null,
1616
1630
  ): TemplateResult {
1617
1631
  const selected = resourceArg.id === this.selectedResourceId;
1618
- const dragging = resourceArg.id === this.draggingResourceId;
1619
- const eligibleTargetCount = this.eligibleSessionIds(resourceArg.id).filter(
1620
- (sessionId) => sessionId !== sessionIdArg,
1621
- ).length;
1632
+ const draggable = indexArg >= 0;
1633
+ const dragging = sameItem(this.draggingItem, { kind: 'resource', id: resourceArg.id });
1622
1634
  const contextOpen = resourceArg.id === this.resourceContextResourceId;
1635
+ const attachedTitle = attachedTarget ? this.itemTitleFor(attachedTarget) : '';
1636
+ const title = resourceArg.title || resourceArg.id;
1637
+ const dropIndex = indexArg < 0 ? 0 : indexArg;
1623
1638
  return html`
1624
- <li
1625
- class="resourceRow ${selected ? 'selected' : ''} ${dragging ? 'resourceDragging' : ''}"
1639
+ <article
1640
+ class="resourceRow ${selected ? 'selected' : ''} ${draggable ? 'draggable' : ''} ${
1641
+ dragging ? 'dragging' : ''
1642
+ }"
1626
1643
  data-resource-id=${resourceArg.id}
1627
- data-resource-session-id=${sessionIdArg ?? ''}
1644
+ data-item-kind="resource"
1645
+ data-item-id=${resourceArg.id}
1646
+ data-resource-target=${attachedTarget ? `${attachedTarget.kind}:${attachedTarget.id}` : ''}
1647
+ data-drop-group-id=${groupIdArg ?? ''}
1648
+ data-drop-index=${dropIndex}
1649
+ data-drop-kind=${indexArg < 0 ? 'slot' : 'card'}
1628
1650
  @dblclick=${(event: MouseEvent) => {
1629
1651
  event.stopPropagation();
1630
- if (this.eventTargetsResourceControl(event)) return;
1631
- this.emitResource('harness-resource-open', resourceArg, sessionIdArg);
1652
+ if (this.eventTargetsResourceControl(event) || this.eventTargetsDragHandle(event)) return;
1653
+ this.emitResource('harness-resource-open', resourceArg, attachedTarget);
1632
1654
  }}
1633
1655
  @contextmenu=${(event: MouseEvent) => {
1634
1656
  event.preventDefault();
@@ -1639,43 +1661,29 @@ export class DeesHarnessSessionList extends DeesElement {
1639
1661
  )) as HTMLElement | undefined)
1640
1662
  ?? (event.currentTarget as HTMLElement).querySelector<HTMLElement>('.resourcePrimary')
1641
1663
  ?? event.currentTarget as HTMLElement;
1642
- this.emitResourceContext(event, resourceArg, sessionIdArg, 'pointer', focusTarget);
1664
+ this.emitResourceContext(event, resourceArg, attachedTarget, 'pointer', focusTarget);
1643
1665
  }}
1644
1666
  >
1645
- <button
1646
- class="resourceDragHandle"
1647
- type="button"
1648
- title=${eligibleTargetCount
1649
- ? 'Drag to associate with an eligible conversation'
1650
- : 'No eligible conversations'}
1651
- aria-label=${`Associate ${resourceArg.title || resourceArg.id} by dragging`}
1652
- aria-keyshortcuts="ContextMenu Shift+F10"
1653
- ?disabled=${eligibleTargetCount === 0}
1654
- @pointerdown=${(event: PointerEvent) => {
1655
- this.handleResourcePointerDown(event, resourceArg, sessionIdArg);
1656
- }}
1657
- @pointermove=${this.handlePointerMove}
1658
- @pointerup=${this.handlePointerUp}
1659
- @pointercancel=${this.handlePointerCancel}
1660
- @lostpointercapture=${this.handleLostPointerCapture}
1661
- @keydown=${(event: KeyboardEvent) => {
1662
- this.handleResourceContextKey(event, resourceArg, sessionIdArg);
1663
- }}
1664
- >
1665
- <dees-icon icon="lucide:GripVertical" iconSize="13"></dees-icon>
1666
- </button>
1667
+ ${draggable
1668
+ ? this.renderDragHandle(
1669
+ { kind: 'resource', id: resourceArg.id },
1670
+ title,
1671
+ groupIdArg,
1672
+ indexArg,
1673
+ )
1674
+ : ''}
1667
1675
  <button
1668
1676
  class="resourcePrimary"
1669
1677
  type="button"
1670
1678
  aria-current=${selected ? 'true' : 'false'}
1671
- @click=${() => this.emitResource('harness-resource-select', resourceArg, sessionIdArg)}
1679
+ @click=${() => this.emitResource('harness-resource-select', resourceArg, attachedTarget)}
1672
1680
  @keydown=${(event: KeyboardEvent) => {
1673
1681
  if (event.key === 'Enter') {
1674
1682
  event.preventDefault();
1675
- this.emitResource('harness-resource-open', resourceArg, sessionIdArg);
1683
+ this.emitResource('harness-resource-open', resourceArg, attachedTarget);
1676
1684
  return;
1677
1685
  }
1678
- this.handleResourceContextKey(event, resourceArg, sessionIdArg);
1686
+ this.handleResourceContextKey(event, resourceArg, attachedTarget);
1679
1687
  }}
1680
1688
  >
1681
1689
  <dees-icon
@@ -1684,7 +1692,7 @@ export class DeesHarnessSessionList extends DeesElement {
1684
1692
  iconSize="14"
1685
1693
  ></dees-icon>
1686
1694
  <span class="resourceCopy">
1687
- <span class="resourceTitle">${resourceArg.title || resourceArg.id}</span>
1695
+ <span class="resourceTitle">${title}</span>
1688
1696
  ${resourceArg.preview ? html`<span class="resourcePreview">${resourceArg.preview}</span>` : ''}
1689
1697
  <span class="resourceMeta">
1690
1698
  ${resourceArg.kind}${resourceArg.state ? ` · ${resourceArg.state}` : ''}${
@@ -1693,27 +1701,42 @@ export class DeesHarnessSessionList extends DeesElement {
1693
1701
  : ''
1694
1702
  }
1695
1703
  </span>
1704
+ ${attachedTarget
1705
+ ? html`
1706
+ <span class="resourceAttachment" title=${`Attached to ${attachedTitle}`}>
1707
+ <dees-icon aria-hidden="true" icon="lucide:Link" iconSize="10"></dees-icon>
1708
+ <span class="visuallyHidden">Attached to </span>
1709
+ <span class="resourceAttachmentName">${attachedTitle}</span>
1710
+ </span>
1711
+ `
1712
+ : ''}
1696
1713
  </span>
1697
1714
  </button>
1698
1715
  <button
1699
1716
  class="resourceActions"
1700
1717
  type="button"
1701
1718
  title="Resource actions"
1702
- aria-label=${`Actions for ${resourceArg.title || resourceArg.id}`}
1719
+ aria-label=${`Actions for ${title}`}
1703
1720
  aria-haspopup="menu"
1704
1721
  aria-expanded=${contextOpen ? 'true' : 'false'}
1705
1722
  aria-keyshortcuts="ContextMenu Shift+F10"
1706
1723
  @click=${(event: MouseEvent) => {
1707
1724
  event.stopPropagation();
1708
- this.emitResourceContext(event, resourceArg, sessionIdArg, 'action', event.currentTarget as HTMLElement);
1725
+ this.emitResourceContext(
1726
+ event,
1727
+ resourceArg,
1728
+ attachedTarget,
1729
+ 'action',
1730
+ event.currentTarget as HTMLElement,
1731
+ );
1709
1732
  }}
1710
1733
  @keydown=${(event: KeyboardEvent) => {
1711
- this.handleResourceContextKey(event, resourceArg, sessionIdArg);
1734
+ this.handleResourceContextKey(event, resourceArg, attachedTarget);
1712
1735
  }}
1713
1736
  >
1714
1737
  <dees-icon icon="lucide:Ellipsis" iconSize="15"></dees-icon>
1715
1738
  </button>
1716
- </li>
1739
+ </article>
1717
1740
  `;
1718
1741
  }
1719
1742
 
@@ -1755,10 +1778,7 @@ export class DeesHarnessSessionList extends DeesElement {
1755
1778
 
1756
1779
  private eventTargetsResourceControl(event: Event): boolean {
1757
1780
  return event.composedPath().some(
1758
- (target) => target instanceof HTMLElement && (
1759
- target.classList.contains('resourceDragHandle')
1760
- || target.classList.contains('resourceActions')
1761
- ),
1781
+ (target) => target instanceof HTMLElement && target.classList.contains('resourceActions'),
1762
1782
  );
1763
1783
  }
1764
1784
 
@@ -1803,29 +1823,9 @@ export class DeesHarnessSessionList extends DeesElement {
1803
1823
  this.collapsedGroupIds = next;
1804
1824
  }
1805
1825
 
1806
- private sortableSessions(
1807
- sessionsArg: IHarnessSessionMeta[],
1808
- excludeDraggingArg = true,
1809
- ): IHarnessSessionMeta[] {
1810
- return sessionsArg.filter((session) => (
1811
- session.draggable !== false
1812
- && (!excludeDraggingArg || session.id !== this.draggingSessionId)
1813
- ));
1814
- }
1815
-
1816
- private sessionsForGroup(groupIdArg: string | null): IHarnessSessionMeta[] {
1817
- if (groupIdArg === null) return this.ungroupedSessions;
1818
- const group = this.groups.find((candidate) => candidate.id === groupIdArg);
1819
- if (!group) return [];
1820
- const byId = this.sessionsById;
1821
- return group.sessionIds
1822
- .map((sessionId) => byId.get(sessionId))
1823
- .filter((session): session is IHarnessSessionMeta => session !== undefined);
1824
- }
1825
-
1826
1826
  private handlePointerDown(
1827
1827
  eventArg: PointerEvent,
1828
- sessionArg: IHarnessSessionMeta,
1828
+ refArg: IHarnessSessionListItemRef,
1829
1829
  groupIdArg: string | null,
1830
1830
  logicalIndexArg: number,
1831
1831
  ): void {
@@ -1836,15 +1836,14 @@ export class DeesHarnessSessionList extends DeesElement {
1836
1836
  || logicalIndexArg < 0
1837
1837
  ) return;
1838
1838
  const handle = eventArg.currentTarget as HTMLElement;
1839
- const source = handle.closest<HTMLElement>('.item');
1839
+ const source = handle.closest<HTMLElement>('.item, .resourceRow');
1840
1840
  if (!source) return;
1841
1841
  const sourceRect = source.getBoundingClientRect();
1842
1842
  this.pointerDragGesture = {
1843
- type: 'session',
1844
1843
  pointerId: eventArg.pointerId,
1845
1844
  handle,
1846
1845
  source,
1847
- sessionId: sessionArg.id,
1846
+ item: itemRef(refArg),
1848
1847
  sourceGroupId: groupIdArg,
1849
1848
  sourceLogicalIndex: logicalIndexArg,
1850
1849
  startClientX: eventArg.clientX,
@@ -1862,44 +1861,6 @@ export class DeesHarnessSessionList extends DeesElement {
1862
1861
  }
1863
1862
  }
1864
1863
 
1865
- private handleResourcePointerDown(
1866
- eventArg: PointerEvent,
1867
- resourceArg: IHarnessResourceMeta,
1868
- fromSessionIdArg: string | null,
1869
- ): void {
1870
- if (
1871
- this.pointerDragGesture
1872
- || eventArg.button !== 0
1873
- || !eventArg.isPrimary
1874
- || this.eligibleSessionIds(resourceArg.id).every((sessionId) => sessionId === fromSessionIdArg)
1875
- ) return;
1876
- const handle = eventArg.currentTarget as HTMLElement;
1877
- const source = handle.closest<HTMLElement>('.resourceRow');
1878
- if (!source) return;
1879
- const sourceRect = source.getBoundingClientRect();
1880
- this.pointerDragGesture = {
1881
- type: 'resource',
1882
- pointerId: eventArg.pointerId,
1883
- handle,
1884
- source,
1885
- resourceId: resourceArg.id,
1886
- fromSessionId: fromSessionIdArg,
1887
- eligibleSessionIds: new Set(this.eligibleSessionIds(resourceArg.id)),
1888
- startClientX: eventArg.clientX,
1889
- startClientY: eventArg.clientY,
1890
- grabOffsetX: eventArg.clientX - sourceRect.left,
1891
- grabOffsetY: eventArg.clientY - sourceRect.top,
1892
- phase: 'pending',
1893
- };
1894
- window.addEventListener('keydown', this.handleDragKeyDown, true);
1895
- window.addEventListener('blur', this.handleDragWindowBlur);
1896
- try {
1897
- handle.setPointerCapture(eventArg.pointerId);
1898
- } catch {
1899
- this.finishPointerDrag(false);
1900
- }
1901
- }
1902
-
1903
1864
  private readonly handlePointerMove = (eventArg: PointerEvent): void => {
1904
1865
  const gesture = this.pointerDragGesture;
1905
1866
  if (!gesture || eventArg.pointerId !== gesture.pointerId) return;
@@ -1914,21 +1875,8 @@ export class DeesHarnessSessionList extends DeesElement {
1914
1875
  eventArg.clientY - gesture.startClientY,
1915
1876
  ) < dragThresholdPixels
1916
1877
  ) return;
1917
- if (
1918
- gesture.phase === 'pending'
1919
- && !(gesture.type === 'session'
1920
- ? this.startPointerDrag(eventArg, gesture)
1921
- : this.startResourcePointerDrag(eventArg, gesture))
1922
- ) return;
1878
+ if (gesture.phase === 'pending' && !this.startPointerDrag(eventArg, gesture)) return;
1923
1879
  eventArg.preventDefault();
1924
- if (gesture.type === 'resource') {
1925
- this.moveResourceDragPreview(eventArg.clientX, eventArg.clientY, gesture);
1926
- this.dragAutoScrollClientX = eventArg.clientX;
1927
- this.dragAutoScrollClientY = eventArg.clientY;
1928
- this.updateDragAutoScrollVelocity();
1929
- this.updateResourceDropTargetFromPointer(eventArg.clientX, eventArg.clientY);
1930
- return;
1931
- }
1932
1880
  this.moveDragPreview(eventArg.clientX, eventArg.clientY, gesture);
1933
1881
  this.dragAutoScrollClientX = eventArg.clientX;
1934
1882
  this.dragAutoScrollClientY = eventArg.clientY;
@@ -1940,14 +1888,6 @@ export class DeesHarnessSessionList extends DeesElement {
1940
1888
  const gesture = this.pointerDragGesture;
1941
1889
  if (!gesture || eventArg.pointerId !== gesture.pointerId) return;
1942
1890
  if (gesture.phase === 'active') {
1943
- if (gesture.type === 'resource') {
1944
- this.dragAutoScrollClientX = eventArg.clientX;
1945
- this.dragAutoScrollClientY = eventArg.clientY;
1946
- this.moveResourceDragPreview(eventArg.clientX, eventArg.clientY, gesture);
1947
- this.updateResourceDropTargetFromPointer(eventArg.clientX, eventArg.clientY);
1948
- this.finishPointerDrag(true);
1949
- return;
1950
- }
1951
1891
  this.dragAutoScrollClientX = eventArg.clientX;
1952
1892
  this.dragAutoScrollClientY = eventArg.clientY;
1953
1893
  this.moveDragPreview(eventArg.clientX, eventArg.clientY, gesture);
@@ -1993,9 +1933,9 @@ export class DeesHarnessSessionList extends DeesElement {
1993
1933
  gestureArg.phase = 'active';
1994
1934
  this.reflowGeneration += 1;
1995
1935
  this.cancelReflowAnimations();
1996
- this.draggedItemHeight = Math.ceil(sourceRect.height);
1997
- this.draggingSessionId = gestureArg.sessionId;
1998
- const remainingCount = this.sortableSessions(this.sessionsForGroup(gestureArg.sourceGroupId)).length;
1936
+ this.draggedItemHeight = sourceRect.height;
1937
+ this.draggingItem = itemRef(gestureArg.item);
1938
+ const remainingCount = this.sortableItems(this.itemsForGroup(gestureArg.sourceGroupId)).length;
1999
1939
  this.dropSlot = {
2000
1940
  groupId: gestureArg.sourceGroupId,
2001
1941
  index: Math.min(gestureArg.sourceLogicalIndex, remainingCount),
@@ -2003,27 +1943,6 @@ export class DeesHarnessSessionList extends DeesElement {
2003
1943
  return true;
2004
1944
  }
2005
1945
 
2006
- private startResourcePointerDrag(
2007
- eventArg: PointerEvent,
2008
- gestureArg: IResourcePointerDragGesture,
2009
- ): boolean {
2010
- const sourceRect = gestureArg.source.getBoundingClientRect();
2011
- gestureArg.source.classList.add('resourceDragging');
2012
- gestureArg.source.style.setProperty('--resource-drag-preview-width', `${sourceRect.width}px`);
2013
- this.moveResourceDragPreview(eventArg.clientX, eventArg.clientY, gestureArg);
2014
- gestureArg.source.setAttribute('popover', 'manual');
2015
- try {
2016
- gestureArg.source.showPopover();
2017
- } catch {
2018
- this.finishPointerDrag(false);
2019
- return false;
2020
- }
2021
- gestureArg.phase = 'active';
2022
- this.draggingResourceId = gestureArg.resourceId;
2023
- this.resourceDropTargetSessionId = '';
2024
- return true;
2025
- }
2026
-
2027
1946
  private moveDragPreview(
2028
1947
  clientXArg: number,
2029
1948
  clientYArg: number,
@@ -2039,49 +1958,21 @@ export class DeesHarnessSessionList extends DeesElement {
2039
1958
  );
2040
1959
  }
2041
1960
 
2042
- private moveResourceDragPreview(
2043
- clientXArg: number,
2044
- clientYArg: number,
2045
- gestureArg: IResourcePointerDragGesture,
2046
- ): void {
2047
- gestureArg.source.style.setProperty(
2048
- '--resource-drag-preview-x',
2049
- `${clientXArg - gestureArg.grabOffsetX}px`,
2050
- );
2051
- gestureArg.source.style.setProperty(
2052
- '--resource-drag-preview-y',
2053
- `${clientYArg - gestureArg.grabOffsetY}px`,
2054
- );
2055
- }
2056
-
2057
- private updateResourceDropTargetFromPointer(clientXArg: number, clientYArg: number): void {
2058
- const gesture = this.pointerDragGesture;
2059
- if (gesture?.type !== 'resource' || gesture.phase !== 'active') return;
2060
- const hit = this.shadowRoot?.elementFromPoint(clientXArg, clientYArg);
2061
- const target = hit?.closest<HTMLElement>('.item[data-session-id]');
2062
- const sessionId = target?.dataset.sessionId ?? '';
2063
- this.resourceDropTargetSessionId = (
2064
- sessionId
2065
- && sessionId !== gesture.fromSessionId
2066
- && this.isResourceTargetEligible(gesture.resourceId, sessionId)
2067
- ) ? sessionId : '';
2068
- }
2069
-
2070
1961
  private setDropSlot(groupIdArg: string | null, indexArg: number): void {
2071
1962
  const current = this.dropSlot;
2072
1963
  if (current && current.groupId === groupIdArg && current.index === indexArg) return;
2073
- const previousRects = this.captureCardRects();
1964
+ const previousRects = this.captureRowRects();
2074
1965
  const generation = ++this.reflowGeneration;
2075
1966
  this.dropSlot = { groupId: groupIdArg, index: indexArg };
2076
- void this.animateCardReflow(previousRects, generation);
1967
+ void this.animateRowReflow(previousRects, generation);
2077
1968
  }
2078
1969
 
2079
1970
  private clearDropSlot(): void {
2080
1971
  if (!this.dropSlot) return;
2081
- const previousRects = this.captureCardRects();
1972
+ const previousRects = this.captureRowRects();
2082
1973
  const generation = ++this.reflowGeneration;
2083
1974
  this.dropSlot = undefined;
2084
- void this.animateCardReflow(previousRects, generation);
1975
+ void this.animateRowReflow(previousRects, generation);
2085
1976
  }
2086
1977
 
2087
1978
  private updateDragAutoScrollVelocity(): void {
@@ -2134,14 +2025,7 @@ export class DeesHarnessSessionList extends DeesElement {
2134
2025
  this.stopDragAutoScroll();
2135
2026
  return;
2136
2027
  }
2137
- if (gesture.type === 'resource') {
2138
- this.updateResourceDropTargetFromPointer(
2139
- this.dragAutoScrollClientX,
2140
- this.dragAutoScrollClientY,
2141
- );
2142
- } else {
2143
- this.updateDropSlotFromPointer();
2144
- }
2028
+ this.updateDropSlotFromPointer();
2145
2029
  this.dragAutoScrollFrame = requestAnimationFrame(this.runDragAutoScroll);
2146
2030
  };
2147
2031
 
@@ -2165,6 +2049,7 @@ export class DeesHarnessSessionList extends DeesElement {
2165
2049
  }
2166
2050
  }
2167
2051
  const selector = '[data-drop-group-id][data-drop-index][data-drop-kind]';
2052
+ const dragging = this.draggingItem;
2168
2053
  let target: HTMLElement | undefined;
2169
2054
  const offsets = this.dragAutoScrollVelocity === 0
2170
2055
  ? [0, -8, 8, -16, 16, -32, 32]
@@ -2176,7 +2061,11 @@ export class DeesHarnessSessionList extends DeesElement {
2176
2061
  const candidate = hit?.closest<HTMLElement>(selector);
2177
2062
  if (
2178
2063
  candidate
2179
- && candidate.dataset.sessionId !== this.draggingSessionId
2064
+ && !(
2065
+ dragging
2066
+ && candidate.dataset.itemKind === dragging.kind
2067
+ && candidate.dataset.itemId === dragging.id
2068
+ )
2180
2069
  && Number(candidate.dataset.dropIndex) >= 0
2181
2070
  ) {
2182
2071
  target = candidate;
@@ -2235,70 +2124,69 @@ export class DeesHarnessSessionList extends DeesElement {
2235
2124
  }
2236
2125
 
2237
2126
  private createMoveDetail(
2238
- sessionIdArg: string,
2239
- groupIdArg: string | null,
2127
+ refArg: IHarnessSessionListItemRef,
2128
+ toGroupIdArg: string | null,
2240
2129
  indexArg: number,
2241
- ): IHarnessSessionMoveDetail {
2242
- const targetSessions = this.sortableSessions(this.sessionsForGroup(groupIdArg));
2243
- const beforeSessionId = targetSessions[Math.max(0, indexArg)]?.id;
2244
- const sourceGroupId = this.groups.find((group) => group.sessionIds.includes(sessionIdArg))?.id ?? null;
2245
- const sourceSessions = this.sortableSessions(this.sessionsForGroup(sourceGroupId), false);
2246
- const sourceIndex = sourceSessions.findIndex((session) => session.id === sessionIdArg);
2247
- const compatibilityIndex = (
2248
- sourceGroupId === groupIdArg
2249
- && sourceIndex >= 0
2250
- && indexArg > sourceIndex
2251
- ) ? indexArg + 1 : indexArg;
2130
+ sourceArg: THarnessItemMoveSource,
2131
+ ): IHarnessItemMoveDetail {
2132
+ const targetItems = this.sortableItems(this.itemsForGroup(toGroupIdArg));
2133
+ const beforeItem = targetItems[Math.max(0, indexArg)];
2252
2134
  return {
2253
- sessionId: sessionIdArg,
2254
- groupId: groupIdArg,
2255
- index: Math.max(0, compatibilityIndex),
2256
- ...(beforeSessionId ? { beforeSessionId } : {}),
2135
+ item: itemRef(refArg),
2136
+ fromGroupId: this.groupIdForItem(refArg),
2137
+ toGroupId: toGroupIdArg,
2138
+ beforeItem: beforeItem ? itemRef(beforeItem) : null,
2139
+ source: sourceArg,
2257
2140
  };
2258
2141
  }
2259
2142
 
2143
+ private emitItemMove(detailArg: IHarnessItemMoveDetail): void {
2144
+ this.dispatchEvent(
2145
+ new CustomEvent('harness-item-move', { detail: detailArg, bubbles: true, composed: true }),
2146
+ );
2147
+ }
2148
+
2260
2149
  private handleKeyboardMove(
2261
2150
  eventArg: KeyboardEvent,
2262
- sessionArg: IHarnessSessionMeta,
2151
+ refArg: IHarnessSessionListItemRef,
2152
+ titleArg: string,
2263
2153
  groupIdArg: string | null,
2264
2154
  ): void {
2265
2155
  if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(eventArg.key)) return;
2266
2156
  eventArg.preventDefault();
2267
2157
  eventArg.stopPropagation();
2268
- const sessions = this.sortableSessions(this.sessionsForGroup(groupIdArg), false);
2269
- const sourceIndex = sessions.findIndex((session) => session.id === sessionArg.id);
2158
+ const items = this.sortableItems(this.itemsForGroup(groupIdArg), false);
2159
+ const sourceIndex = items.findIndex((item) => sameItem(item, refArg));
2270
2160
  if (sourceIndex < 0) return;
2271
- const sectionIds: Array<string | null> = [...this.groups.map((group) => group.id), null];
2161
+ const sectionIds: Array<string | null> = [...this.effectiveGroups.map((group) => group.id), null];
2272
2162
  const sectionIndex = sectionIds.indexOf(groupIdArg);
2273
2163
  const adjacentGroupId = eventArg.key === 'ArrowUp' && sourceIndex === 0 && sectionIndex > 0
2274
2164
  ? sectionIds[sectionIndex - 1]
2275
2165
  : eventArg.key === 'ArrowDown'
2276
- && sourceIndex === sessions.length - 1
2166
+ && sourceIndex === items.length - 1
2277
2167
  && sectionIndex >= 0
2278
2168
  && sectionIndex < sectionIds.length - 1
2279
2169
  ? sectionIds[sectionIndex + 1]
2280
2170
  : undefined;
2281
2171
  if (adjacentGroupId !== undefined) {
2282
- const targetSessions = this.sortableSessions(this.sessionsForGroup(adjacentGroupId), false);
2283
- const targetIndex = eventArg.key === 'ArrowUp' ? targetSessions.length : 0;
2284
- const beforeSession = targetSessions[targetIndex];
2285
- const detail: IHarnessSessionMoveDetail = {
2286
- sessionId: sessionArg.id,
2287
- groupId: adjacentGroupId,
2288
- index: targetIndex,
2289
- ...(beforeSession ? { beforeSessionId: beforeSession.id } : {}),
2290
- };
2172
+ const targetItems = this.sortableItems(this.itemsForGroup(adjacentGroupId), false);
2173
+ const targetIndex = eventArg.key === 'ArrowUp' ? targetItems.length : 0;
2174
+ const beforeItem = targetItems[targetIndex];
2291
2175
  const groupName = adjacentGroupId === null
2292
2176
  ? 'Ungrouped'
2293
- : this.groups.find((group) => group.id === adjacentGroupId)?.name ?? adjacentGroupId;
2294
- this.moveAnnouncement = `Moved ${sessionArg.title || sessionArg.id} to ${groupName}.`;
2295
- this.keyboardMoveFocus = { sessionId: sessionArg.id, groupId: adjacentGroupId };
2296
- this.dispatchEvent(
2297
- new CustomEvent('harness-session-move', { detail, bubbles: true, composed: true }),
2298
- );
2177
+ : this.effectiveGroups.find((group) => group.id === adjacentGroupId)?.name ?? adjacentGroupId;
2178
+ this.moveAnnouncement = `Moved ${titleArg} to ${groupName}.`;
2179
+ this.keyboardMoveFocus = { item: itemRef(refArg), groupId: adjacentGroupId };
2180
+ this.emitItemMove({
2181
+ item: itemRef(refArg),
2182
+ fromGroupId: groupIdArg,
2183
+ toGroupId: adjacentGroupId,
2184
+ beforeItem: beforeItem ? itemRef(beforeItem) : null,
2185
+ source: 'keyboard',
2186
+ });
2299
2187
  return;
2300
2188
  }
2301
- const remaining = sessions.filter((session) => session.id !== sessionArg.id);
2189
+ const remaining = items.filter((item) => !sameItem(item, refArg));
2302
2190
  const targetIndex = eventArg.key === 'Home'
2303
2191
  ? 0
2304
2192
  : eventArg.key === 'End'
@@ -2307,27 +2195,30 @@ export class DeesHarnessSessionList extends DeesElement {
2307
2195
  ? Math.max(0, sourceIndex - 1)
2308
2196
  : Math.min(remaining.length, sourceIndex + 1);
2309
2197
  if (targetIndex === sourceIndex) return;
2310
- const beforeSession = remaining[targetIndex];
2311
- const compatibilityIndex = targetIndex > sourceIndex ? targetIndex + 1 : targetIndex;
2312
- const detail: IHarnessSessionMoveDetail = {
2313
- sessionId: sessionArg.id,
2314
- groupId: groupIdArg,
2315
- index: compatibilityIndex,
2316
- ...(beforeSession ? { beforeSessionId: beforeSession.id } : {}),
2317
- };
2318
- this.moveAnnouncement = beforeSession
2319
- ? `Moved ${sessionArg.title || sessionArg.id} before ${beforeSession.title || beforeSession.id}.`
2320
- : `Moved ${sessionArg.title || sessionArg.id} to the end.`;
2321
- this.dispatchEvent(
2322
- new CustomEvent('harness-session-move', { detail, bubbles: true, composed: true }),
2323
- );
2198
+ const beforeItem = remaining[targetIndex];
2199
+ this.moveAnnouncement = beforeItem
2200
+ ? `Moved ${titleArg} before ${beforeItem.title}.`
2201
+ : `Moved ${titleArg} to the end.`;
2202
+ this.emitItemMove({
2203
+ item: itemRef(refArg),
2204
+ fromGroupId: groupIdArg,
2205
+ toGroupId: groupIdArg,
2206
+ beforeItem: beforeItem ? itemRef(beforeItem) : null,
2207
+ source: 'keyboard',
2208
+ });
2209
+ }
2210
+
2211
+ private rowKey(rowArg: HTMLElement): string {
2212
+ return `${rowArg.dataset.itemKind}:${rowArg.dataset.itemId}`;
2324
2213
  }
2325
2214
 
2326
- private captureCardRects(): Map<string, DOMRect> {
2215
+ private captureRowRects(): Map<string, DOMRect> {
2327
2216
  const result = new Map<string, DOMRect>();
2328
- for (const item of this.shadowRoot?.querySelectorAll<HTMLElement>('.item[data-session-id]') ?? []) {
2329
- if (item.dataset.sessionId === this.draggingSessionId) continue;
2330
- result.set(item.dataset.sessionId ?? '', item.getBoundingClientRect());
2217
+ const draggingKey = this.draggingItem ? itemKey(this.draggingItem) : '';
2218
+ for (const row of this.shadowRoot?.querySelectorAll<HTMLElement>('[data-item-kind][data-item-id]') ?? []) {
2219
+ const key = this.rowKey(row);
2220
+ if (key === draggingKey) continue;
2221
+ result.set(key, row.getBoundingClientRect());
2331
2222
  }
2332
2223
  return result;
2333
2224
  }
@@ -2335,14 +2226,8 @@ export class DeesHarnessSessionList extends DeesElement {
2335
2226
  private restoreKeyboardMoveFocus(): void {
2336
2227
  const pending = this.keyboardMoveFocus;
2337
2228
  if (!pending) return;
2338
- const currentGroupId = this.groups.find(
2339
- (group) => group.sessionIds.includes(pending.sessionId),
2340
- )?.id ?? null;
2341
- if (currentGroupId !== pending.groupId) return;
2342
- const item = Array.from(
2343
- this.shadowRoot?.querySelectorAll<HTMLElement>('.item[data-session-id]') ?? [],
2344
- ).find((candidate) => candidate.dataset.sessionId === pending.sessionId);
2345
- const handle = item?.querySelector<HTMLElement>('.dragHandle');
2229
+ if (this.groupIdForItem(pending.item) !== pending.groupId) return;
2230
+ const handle = this.rowElementFor(pending.item)?.querySelector<HTMLElement>('.dragHandle');
2346
2231
  if (handle) {
2347
2232
  handle.focus();
2348
2233
  this.keyboardMoveFocus = undefined;
@@ -2357,35 +2242,52 @@ export class DeesHarnessSessionList extends DeesElement {
2357
2242
  }
2358
2243
  }
2359
2244
 
2360
- private eligibleSessionIds(resourceIdArg: string): string[] {
2361
- const knownSessionIds = new Set(this.sessions.map((session) => session.id));
2362
- const result: string[] = [];
2245
+ /** Known, de-duplicated targets the host allows; an absent entry means unrestricted. */
2246
+ private eligibleTargets(resourceIdArg: string): IHarnessSessionListItemRef[] | undefined {
2247
+ const declared = this.eligibleTargetsByResourceId[resourceIdArg];
2248
+ if (!declared) return undefined;
2249
+ const itemsByKey = this.itemsByKey;
2250
+ const result: IHarnessSessionListItemRef[] = [];
2363
2251
  const seen = new Set<string>();
2364
- for (const sessionId of this.eligibleSessionIdsByResourceId[resourceIdArg] ?? []) {
2365
- if (!knownSessionIds.has(sessionId) || seen.has(sessionId)) continue;
2366
- seen.add(sessionId);
2367
- result.push(sessionId);
2252
+ for (const target of declared) {
2253
+ const key = target && itemKey(target);
2254
+ if (
2255
+ !key
2256
+ || seen.has(key)
2257
+ || !itemsByKey.has(key)
2258
+ || sameItem(target, { kind: 'resource', id: resourceIdArg })
2259
+ ) continue;
2260
+ seen.add(key);
2261
+ result.push(itemRef(target));
2368
2262
  }
2369
2263
  return result;
2370
2264
  }
2371
2265
 
2372
- private sameSessionIds(firstArg: ReadonlySet<string>, secondArg: ReadonlySet<string>): boolean {
2373
- return firstArg.size === secondArg.size
2374
- && [...firstArg].every((sessionId) => secondArg.has(sessionId));
2266
+ private isResourceTargetEligible(
2267
+ resourceIdArg: string,
2268
+ targetArg: IHarnessSessionListItemRef,
2269
+ ): boolean {
2270
+ const eligible = this.eligibleTargets(resourceIdArg);
2271
+ return eligible === undefined || eligible.some((candidate) => sameItem(candidate, targetArg));
2272
+ }
2273
+
2274
+ private itemTitleFor(refArg: IHarnessSessionListItemRef): string {
2275
+ return this.itemsByKey.get(itemKey(refArg))?.title ?? refArg.id;
2375
2276
  }
2376
2277
 
2377
- private isResourceTargetEligible(resourceIdArg: string, sessionIdArg: string): boolean {
2378
- return this.eligibleSessionIds(resourceIdArg).includes(sessionIdArg);
2278
+ private emitSession(nameArg: string, sessionArg: IHarnessSessionMeta): void {
2279
+ const detail: IHarnessSessionEventDetail = { session: sessionArg };
2280
+ this.dispatchEvent(new CustomEvent(nameArg, { detail, bubbles: true, composed: true }));
2379
2281
  }
2380
2282
 
2381
2283
  private emitResource(
2382
2284
  nameArg: string,
2383
2285
  resourceArg: IHarnessResourceMeta,
2384
- sessionIdArg: string | null,
2286
+ targetArg: IHarnessSessionListItemRef | null,
2385
2287
  ): void {
2386
2288
  const detail: IHarnessResourceEventDetail = {
2387
2289
  resource: resourceArg,
2388
- sessionId: sessionIdArg,
2290
+ target: targetArg,
2389
2291
  };
2390
2292
  this.dispatchEvent(new CustomEvent(nameArg, { detail, bubbles: true, composed: true }));
2391
2293
  }
@@ -2393,7 +2295,7 @@ export class DeesHarnessSessionList extends DeesElement {
2393
2295
  private handleResourceContextKey(
2394
2296
  eventArg: KeyboardEvent,
2395
2297
  resourceArg: IHarnessResourceMeta,
2396
- sessionIdArg: string | null,
2298
+ targetArg: IHarnessSessionListItemRef | null,
2397
2299
  ): void {
2398
2300
  if (!(eventArg.key === 'ContextMenu' || (eventArg.key === 'F10' && eventArg.shiftKey))) return;
2399
2301
  eventArg.preventDefault();
@@ -2401,7 +2303,7 @@ export class DeesHarnessSessionList extends DeesElement {
2401
2303
  this.emitResourceContext(
2402
2304
  eventArg,
2403
2305
  resourceArg,
2404
- sessionIdArg,
2306
+ targetArg,
2405
2307
  'keyboard',
2406
2308
  eventArg.currentTarget as HTMLElement,
2407
2309
  );
@@ -2410,7 +2312,7 @@ export class DeesHarnessSessionList extends DeesElement {
2410
2312
  private emitResourceContext(
2411
2313
  eventArg: MouseEvent | KeyboardEvent,
2412
2314
  resourceArg: IHarnessResourceMeta,
2413
- sessionIdArg: string | null,
2315
+ targetArg: IHarnessSessionListItemRef | null,
2414
2316
  sourceArg: THarnessResourceContextSource,
2415
2317
  focusTargetArg: HTMLElement,
2416
2318
  ): void {
@@ -2425,7 +2327,7 @@ export class DeesHarnessSessionList extends DeesElement {
2425
2327
  ].find((className) => focusTargetArg.classList.contains(className)) ?? 'resourcePrimary';
2426
2328
  const detail: IHarnessResourceContextDetail = {
2427
2329
  resource: resourceArg,
2428
- sessionId: sessionIdArg,
2330
+ target: targetArg,
2429
2331
  clientX: sourceArg === 'pointer' && eventArg instanceof MouseEvent
2430
2332
  ? eventArg.clientX
2431
2333
  : rect.left + rect.width / 2,
@@ -2434,9 +2336,9 @@ export class DeesHarnessSessionList extends DeesElement {
2434
2336
  : rect.bottom,
2435
2337
  source: sourceArg,
2436
2338
  originalEvent: eventArg,
2437
- requestAssociation: (toSessionIdArg) => this.requestResourceAssociation(
2339
+ requestAssociation: (toTargetArg) => this.requestResourceAssociation(
2438
2340
  resourceArg.id,
2439
- toSessionIdArg,
2341
+ toTargetArg,
2440
2342
  sourceArg === 'keyboard' ? 'keyboard' : 'action',
2441
2343
  ),
2442
2344
  close: () => this.closeResourceContext(contextGeneration),
@@ -2462,9 +2364,7 @@ export class DeesHarnessSessionList extends DeesElement {
2462
2364
  focusTarget.focus();
2463
2365
  return;
2464
2366
  }
2465
- const row = Array.from(
2466
- this.shadowRoot?.querySelectorAll<HTMLElement>('.resourceRow[data-resource-id]') ?? [],
2467
- ).find((candidate) => candidate.dataset.resourceId === resourceId);
2367
+ const row = this.rowElementFor({ kind: 'resource', id: resourceId });
2468
2368
  row?.querySelector<HTMLElement>(`.${focusClass || 'resourcePrimary'}`)?.focus();
2469
2369
  });
2470
2370
  }
@@ -2483,28 +2383,22 @@ export class DeesHarnessSessionList extends DeesElement {
2483
2383
  this.pendingResourceAssociationRequest = undefined;
2484
2384
  return;
2485
2385
  }
2486
- const currentSessionId = this.acceptedResourceAssociations.get(pending.resourceId) ?? null;
2487
- if (currentSessionId !== pending.toSessionId) {
2488
- if (currentSessionId !== pending.fromSessionId) {
2489
- const controlledSession = currentSessionId
2490
- ? this.sessionsById.get(currentSessionId)?.title ?? currentSessionId
2491
- : 'unattached';
2492
- this.moveAnnouncement = `Association request for ${pending.resourceTitle} was not applied; controlled association is ${controlledSession}.`;
2386
+ const currentTarget = this.acceptedResourceAssociations.get(pending.resourceId) ?? null;
2387
+ if (!sameItem(currentTarget, pending.toTarget) && !(currentTarget === null && pending.toTarget === null)) {
2388
+ if (!sameItem(currentTarget, pending.fromTarget) && !(currentTarget === null && pending.fromTarget === null)) {
2389
+ const controlledTitle = currentTarget ? this.itemTitleFor(currentTarget) : 'unattached';
2390
+ this.moveAnnouncement = `Association request for ${pending.resourceTitle} was not applied; controlled association is ${controlledTitle}.`;
2493
2391
  this.pendingResourceAssociationRequest = undefined;
2494
2392
  }
2495
2393
  return;
2496
2394
  }
2497
- const fromSession = pending.fromSessionId
2498
- ? this.sessionsById.get(pending.fromSessionId)?.title ?? pending.fromSessionId
2499
- : '';
2500
- const toSession = pending.toSessionId
2501
- ? this.sessionsById.get(pending.toSessionId)?.title ?? pending.toSessionId
2502
- : '';
2395
+ const fromTitle = pending.fromTarget ? this.itemTitleFor(pending.fromTarget) : '';
2396
+ const toTitle = pending.toTarget ? this.itemTitleFor(pending.toTarget) : '';
2503
2397
  this.moveAnnouncement = pending.intent === 'attach'
2504
- ? `${pending.resourceTitle} is now attached to ${toSession}.`
2398
+ ? `${pending.resourceTitle} is now attached to ${toTitle}.`
2505
2399
  : pending.intent === 'detach'
2506
- ? `${pending.resourceTitle} is now detached from ${fromSession}.`
2507
- : `${pending.resourceTitle} is now reassigned from ${fromSession} to ${toSession}.`;
2400
+ ? `${pending.resourceTitle} is now detached from ${fromTitle}.`
2401
+ : `${pending.resourceTitle} is now reassigned from ${fromTitle} to ${toTitle}.`;
2508
2402
  this.pendingResourceAssociationRequest = undefined;
2509
2403
  }
2510
2404
 
@@ -2513,12 +2407,8 @@ export class DeesHarnessSessionList extends DeesElement {
2513
2407
  completedArg: boolean,
2514
2408
  ): string {
2515
2409
  const resourceTitle = detailArg.resource.title || detailArg.resource.id;
2516
- const fromTitle = detailArg.fromSessionId
2517
- ? this.sessionsById.get(detailArg.fromSessionId)?.title ?? detailArg.fromSessionId
2518
- : '';
2519
- const toTitle = detailArg.toSessionId
2520
- ? this.sessionsById.get(detailArg.toSessionId)?.title ?? detailArg.toSessionId
2521
- : '';
2410
+ const fromTitle = detailArg.fromTarget ? this.itemTitleFor(detailArg.fromTarget) : '';
2411
+ const toTitle = detailArg.toTarget ? this.itemTitleFor(detailArg.toTarget) : '';
2522
2412
  const verb = completedArg ? 'completed' : 'requested';
2523
2413
  return detailArg.intent === 'attach'
2524
2414
  ? `Attach ${verb} for ${resourceTitle} to ${toTitle}.`
@@ -2527,7 +2417,7 @@ export class DeesHarnessSessionList extends DeesElement {
2527
2417
  : `Reassign ${verb} for ${resourceTitle} from ${fromTitle} to ${toTitle}.`;
2528
2418
  }
2529
2419
 
2530
- private async animateCardReflow(
2420
+ private async animateRowReflow(
2531
2421
  previousRectsArg: Map<string, DOMRect>,
2532
2422
  generationArg: number,
2533
2423
  ): Promise<void> {
@@ -2538,15 +2428,16 @@ export class DeesHarnessSessionList extends DeesElement {
2538
2428
  || globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches
2539
2429
  ) return;
2540
2430
  this.cancelReflowAnimations();
2541
- for (const item of this.shadowRoot?.querySelectorAll<HTMLElement>('.item[data-session-id]') ?? []) {
2542
- const sessionId = item.dataset.sessionId ?? '';
2543
- if (!sessionId || sessionId === this.draggingSessionId) continue;
2544
- const previous = previousRectsArg.get(sessionId);
2431
+ const draggingKey = this.draggingItem ? itemKey(this.draggingItem) : '';
2432
+ for (const row of this.shadowRoot?.querySelectorAll<HTMLElement>('[data-item-kind][data-item-id]') ?? []) {
2433
+ const key = this.rowKey(row);
2434
+ if (key === draggingKey) continue;
2435
+ const previous = previousRectsArg.get(key);
2545
2436
  if (!previous) continue;
2546
- const current = item.getBoundingClientRect();
2437
+ const current = row.getBoundingClientRect();
2547
2438
  const deltaY = previous.top - current.top;
2548
2439
  if (Math.abs(deltaY) < 0.5) continue;
2549
- const animation = item.animate(
2440
+ const animation = row.animate(
2550
2441
  [
2551
2442
  { transform: `translateY(${deltaY}px)` },
2552
2443
  { transform: 'translateY(0)' },
@@ -2568,24 +2459,8 @@ export class DeesHarnessSessionList extends DeesElement {
2568
2459
  private finishPointerDrag(commitArg: boolean): void {
2569
2460
  const gesture = this.pointerDragGesture;
2570
2461
  if (!gesture) return;
2571
- const sessionMoveDetail = (
2572
- gesture.type === 'session'
2573
- && commitArg
2574
- && gesture.phase === 'active'
2575
- && this.dropSlot
2576
- )
2577
- ? this.createMoveDetail(gesture.sessionId, this.dropSlot.groupId, this.dropSlot.index)
2578
- : undefined;
2579
- const resourceRequest = (
2580
- gesture.type === 'resource'
2581
- && commitArg
2582
- && gesture.phase === 'active'
2583
- && this.resourceDropTargetSessionId
2584
- )
2585
- ? {
2586
- resourceId: gesture.resourceId,
2587
- toSessionId: this.resourceDropTargetSessionId,
2588
- }
2462
+ const moveDetail = (commitArg && gesture.phase === 'active' && this.dropSlot)
2463
+ ? this.createMoveDetail(gesture.item, this.dropSlot.groupId, this.dropSlot.index, 'pointer')
2589
2464
  : undefined;
2590
2465
  this.pointerDragGesture = undefined;
2591
2466
  window.removeEventListener('keydown', this.handleDragKeyDown, true);
@@ -2595,19 +2470,14 @@ export class DeesHarnessSessionList extends DeesElement {
2595
2470
  this.cancelReflowAnimations();
2596
2471
  if (gesture.source.matches(':popover-open')) gesture.source.hidePopover();
2597
2472
  gesture.source.removeAttribute('popover');
2598
- gesture.source.classList.remove('dragging', 'resourceDragging');
2473
+ gesture.source.classList.remove('dragging');
2599
2474
  for (const property of [
2600
2475
  '--drag-preview-width',
2601
2476
  '--drag-preview-height',
2602
2477
  '--drag-preview-x',
2603
2478
  '--drag-preview-y',
2604
- '--resource-drag-preview-width',
2605
- '--resource-drag-preview-x',
2606
- '--resource-drag-preview-y',
2607
2479
  ]) gesture.source.style.removeProperty(property);
2608
- this.draggingSessionId = '';
2609
- this.draggingResourceId = '';
2610
- this.resourceDropTargetSessionId = '';
2480
+ this.draggingItem = undefined;
2611
2481
  this.draggedItemHeight = 0;
2612
2482
  this.dropSlot = undefined;
2613
2483
  try {
@@ -2617,21 +2487,6 @@ export class DeesHarnessSessionList extends DeesElement {
2617
2487
  } catch {
2618
2488
  // Capture may already be released or the handle may have disconnected.
2619
2489
  }
2620
- if (sessionMoveDetail) {
2621
- this.dispatchEvent(
2622
- new CustomEvent('harness-session-move', {
2623
- detail: sessionMoveDetail,
2624
- bubbles: true,
2625
- composed: true,
2626
- }),
2627
- );
2628
- }
2629
- if (resourceRequest) {
2630
- this.requestResourceAssociation(resourceRequest.resourceId, resourceRequest.toSessionId, 'drop');
2631
- }
2632
- }
2633
-
2634
- private emit(name: string, session: IHarnessSessionMeta): void {
2635
- this.dispatchEvent(new CustomEvent(name, { detail: { session }, bubbles: true, composed: true }));
2490
+ if (moveDetail) this.emitItemMove(moveDetail);
2636
2491
  }
2637
2492
  }