@design.estate/dees-catalog 10.0.0 → 11.1.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.
Files changed (26) hide show
  1. package/dist_bundle/bundle.js +914 -780
  2. package/dist_bundle/bundle.js.map +1 -1
  3. package/dist_ts_web/00_commitinfo_data.js +1 -1
  4. package/dist_ts_web/elements/00group-harness/dees-harness-chat/dees-harness-chat.demo.js +24 -1
  5. package/dist_ts_web/elements/00group-harness/dees-harness-message-list/dees-harness-message-list.js +13 -1
  6. package/dist_ts_web/elements/00group-harness/dees-harness-session-list/dees-harness-session-list.d.ts +62 -48
  7. package/dist_ts_web/elements/00group-harness/dees-harness-session-list/dees-harness-session-list.demo.js +97 -57
  8. package/dist_ts_web/elements/00group-harness/dees-harness-session-list/dees-harness-session-list.js +541 -666
  9. package/dist_ts_web/elements/00group-harness/dees-harness-tool-card/dees-harness-tool-card.js +19 -1
  10. package/dist_ts_web/elements/00group-harness/dees-terminal-view/dees-terminal-view.d.ts +45 -6
  11. package/dist_ts_web/elements/00group-harness/dees-terminal-view/dees-terminal-view.js +184 -27
  12. package/dist_ts_web/elements/00group-harness/interfaces.d.ts +59 -20
  13. package/dist_ts_web/services/DeesServiceLibLoader.d.ts +7 -0
  14. package/dist_ts_web/services/DeesServiceLibLoader.js +45 -22
  15. package/package.json +1 -2
  16. package/readme.md +37 -17
  17. package/ts_web/00_commitinfo_data.ts +1 -1
  18. package/ts_web/elements/00group-harness/dees-harness-chat/dees-harness-chat.demo.ts +23 -0
  19. package/ts_web/elements/00group-harness/dees-harness-message-list/dees-harness-message-list.ts +12 -0
  20. package/ts_web/elements/00group-harness/dees-harness-session-list/dees-harness-session-list.demo.ts +107 -55
  21. package/ts_web/elements/00group-harness/dees-harness-session-list/dees-harness-session-list.ts +622 -756
  22. package/ts_web/elements/00group-harness/dees-harness-tool-card/dees-harness-tool-card.ts +18 -0
  23. package/ts_web/elements/00group-harness/dees-terminal-view/dees-terminal-view.ts +196 -24
  24. package/ts_web/elements/00group-harness/interfaces.ts +63 -19
  25. package/ts_web/services/DeesServiceLibLoader.ts +150 -102
  26. package/.smartconfig.json +0 -86
@@ -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;
@@ -512,7 +528,8 @@ export class DeesHarnessSessionList extends DeesElement {
512
528
  line-height: 14px;
513
529
  }
514
530
 
515
- .harnessName {
531
+ .harnessName,
532
+ .projectName {
516
533
  min-width: 0;
517
534
  overflow: hidden;
518
535
  text-overflow: ellipsis;
@@ -592,75 +609,31 @@ export class DeesHarnessSessionList extends DeesElement {
592
609
  font-size: var(--dees-font-caption2-size, 10px);
593
610
  }
594
611
 
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
-
612
+ /* Resource rows are peers of session cards in the same ordered section. */
634
613
  .resourceRow {
614
+ position: relative;
635
615
  display: grid;
636
- grid-template-columns: 26px minmax(0, 1fr) 38px;
616
+ grid-template-columns: minmax(0, 1fr) 38px;
637
617
  min-width: 0;
638
618
  overflow: hidden;
639
619
  border: 1px solid var(--dees-color-border-subtle);
640
620
  border-radius: var(--dees-radius-lg);
621
+ corner-shape: var(--dees-corner-shape);
641
622
  background: color-mix(in srgb, var(--dees-color-bg-secondary) 76%, transparent);
642
623
  color: var(--dees-color-text-primary);
643
624
  box-shadow: var(--dees-shadow-xs);
644
625
  box-sizing: border-box;
645
626
  }
646
627
 
628
+ .resourceRow.draggable {
629
+ grid-template-columns: 26px minmax(0, 1fr) 38px;
630
+ }
631
+
647
632
  .resourceRow.selected {
648
633
  border-color: var(--dees-color-accent-primary);
649
634
  background: color-mix(in srgb, var(--dees-color-accent-primary) 10%, var(--dees-color-bg-secondary));
650
635
  }
651
636
 
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
637
  .resourceDragHandle,
665
638
  .resourcePrimary,
666
639
  .resourceActions {
@@ -672,22 +645,7 @@ export class DeesHarnessSessionList extends DeesElement {
672
645
  }
673
646
 
674
647
  .resourceDragHandle {
675
- display: grid;
676
- place-items: center;
677
648
  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
649
  }
692
650
 
693
651
  .resourcePrimary {
@@ -741,6 +699,29 @@ export class DeesHarnessSessionList extends DeesElement {
741
699
  white-space: nowrap;
742
700
  }
743
701
 
702
+ /* Decorative marker for the controlled association; never interactive. */
703
+ .resourceAttachment {
704
+ display: flex;
705
+ min-width: 0;
706
+ align-items: center;
707
+ gap: 4px;
708
+ margin-top: 2px;
709
+ color: var(--dees-color-link);
710
+ font-size: var(--dees-font-caption2-size, 10px);
711
+ font-weight: 600;
712
+ }
713
+
714
+ .resourceAttachment dees-icon {
715
+ flex: 0 0 auto;
716
+ }
717
+
718
+ .resourceAttachmentName {
719
+ min-width: 0;
720
+ overflow: hidden;
721
+ text-overflow: ellipsis;
722
+ white-space: nowrap;
723
+ }
724
+
744
725
  .resourceActions {
745
726
  display: grid;
746
727
  place-items: center;
@@ -778,7 +759,7 @@ export class DeesHarnessSessionList extends DeesElement {
778
759
  .emptyDropZone {
779
760
  display: grid;
780
761
  place-items: center;
781
- min-height: 46px;
762
+ overflow: hidden;
782
763
  border: 1px dashed color-mix(in srgb, var(--dees-color-accent-primary) 68%, transparent);
783
764
  border-radius: var(--dees-radius-xl);
784
765
  background: color-mix(in srgb, var(--dees-color-accent-primary) 8%, transparent);
@@ -786,9 +767,14 @@ export class DeesHarnessSessionList extends DeesElement {
786
767
  box-sizing: border-box;
787
768
  }
788
769
 
770
+ /* The placeholder matches the dragged row exactly; only the empty zone has a floor. */
771
+ .emptyDropZone {
772
+ min-height: 46px;
773
+ }
774
+
789
775
  .dropPlaceholder::after,
790
776
  .emptyDropZone::after {
791
- content: 'Drop conversation here';
777
+ content: 'Drop here';
792
778
  font-size: var(--dees-font-caption2-size, 10px);
793
779
  font-weight: 650;
794
780
  letter-spacing: 0.02em;
@@ -931,6 +917,10 @@ export class DeesHarnessSessionList extends DeesElement {
931
917
  .expandToggle { margin: 0 3px 0 0; }
932
918
 
933
919
  .resourceRow {
920
+ grid-template-columns: minmax(0, 1fr) 44px;
921
+ }
922
+
923
+ .resourceRow.draggable {
934
924
  grid-template-columns: 44px minmax(0, 1fr) 44px;
935
925
  }
936
926
 
@@ -947,10 +937,6 @@ export class DeesHarnessSessionList extends DeesElement {
947
937
  }
948
938
 
949
939
  @media (max-width: 280px) {
950
- .resourceChildren {
951
- margin-left: 8px;
952
- }
953
-
954
940
  .resourcePreview {
955
941
  display: none;
956
942
  }
@@ -958,10 +944,6 @@ export class DeesHarnessSessionList extends DeesElement {
958
944
  `,
959
945
  ];
960
946
 
961
- private get sessionsById(): Map<string, IHarnessSessionMeta> {
962
- return new Map(this.sessions.map((session) => [session.id, session]));
963
- }
964
-
965
947
  private get resourcesById(): Map<string, IHarnessResourceMeta> {
966
948
  const result = new Map<string, IHarnessResourceMeta>();
967
949
  for (const resource of this.resources) {
@@ -971,18 +953,23 @@ export class DeesHarnessSessionList extends DeesElement {
971
953
  return result;
972
954
  }
973
955
 
974
- /** First valid record wins; unknown and duplicate records remain untouched but are not projected. */
975
- private get acceptedResourceAssociations(): Map<string, string> {
956
+ /**
957
+ * First valid record wins; unknown records, self-references and duplicates
958
+ * remain untouched but are not projected. Keyed by resource id.
959
+ */
960
+ private get acceptedResourceAssociations(): Map<string, IHarnessSessionListItemRef> {
976
961
  const resourcesById = this.resourcesById;
977
- const sessionsById = this.sessionsById;
978
- const result = new Map<string, string>();
962
+ const itemsByKey = this.itemsByKey;
963
+ const result = new Map<string, IHarnessSessionListItemRef>();
979
964
  for (const association of this.resourceAssociations) {
980
965
  if (
981
- !resourcesById.has(association.resourceId)
982
- || !sessionsById.has(association.sessionId)
966
+ !association.target
967
+ || !resourcesById.has(association.resourceId)
968
+ || !itemsByKey.has(itemKey(association.target))
969
+ || sameItem(association.target, { kind: 'resource', id: association.resourceId })
983
970
  || result.has(association.resourceId)
984
971
  ) continue;
985
- result.set(association.resourceId, association.sessionId);
972
+ result.set(association.resourceId, itemRef(association.target));
986
973
  }
987
974
  return result;
988
975
  }
@@ -991,8 +978,95 @@ export class DeesHarnessSessionList extends DeesElement {
991
978
  return [...this.resourcesById.values()];
992
979
  }
993
980
 
981
+ /** Sessions and resources as one pool of orderable items, before grouping and search. */
982
+ private get acceptedItems(): TListItem[] {
983
+ const result: TListItem[] = [];
984
+ const seenSessionIds = new Set<string>();
985
+ for (const session of this.sessions) {
986
+ if (!session.id || seenSessionIds.has(session.id)) continue;
987
+ seenSessionIds.add(session.id);
988
+ result.push({
989
+ kind: 'session',
990
+ key: `session:${session.id}`,
991
+ id: session.id,
992
+ title: session.title || session.id,
993
+ sortable: session.draggable !== false,
994
+ timestamp: session.updatedAt ?? session.createdAt ?? 0,
995
+ session,
996
+ });
997
+ }
998
+ for (const resource of this.resourcesById.values()) {
999
+ result.push({
1000
+ kind: 'resource',
1001
+ key: `resource:${resource.id}`,
1002
+ id: resource.id,
1003
+ title: resource.title || resource.id,
1004
+ sortable: resource.draggable !== false,
1005
+ timestamp: resource.updatedAt ?? resource.createdAt ?? 0,
1006
+ resource,
1007
+ });
1008
+ }
1009
+ return result;
1010
+ }
1011
+
1012
+ private get itemsByKey(): Map<string, TListItem> {
1013
+ return new Map(this.acceptedItems.map((item) => [item.key, item]));
1014
+ }
1015
+
1016
+ /** Groups only organize the list while grouping is enabled. */
1017
+ private get effectiveGroups(): IHarnessSessionGroup[] {
1018
+ return this.enableGrouping ? this.groups : [];
1019
+ }
1020
+
1021
+ private get groupedItemKeys(): Set<string> {
1022
+ const keys = new Set<string>();
1023
+ for (const group of this.effectiveGroups) {
1024
+ for (const ref of group.itemIds) keys.add(itemKey(ref));
1025
+ }
1026
+ return keys;
1027
+ }
1028
+
1029
+ private itemsForGroup(groupIdArg: string | null): TListItem[] {
1030
+ if (groupIdArg === null) return this.ungroupedItems;
1031
+ const group = this.effectiveGroups.find((candidate) => candidate.id === groupIdArg);
1032
+ if (!group) return [];
1033
+ const byKey = this.itemsByKey;
1034
+ const seen = new Set<string>();
1035
+ const result: TListItem[] = [];
1036
+ for (const ref of group.itemIds) {
1037
+ const key = itemKey(ref);
1038
+ const item = byKey.get(key);
1039
+ if (!item || seen.has(key)) continue;
1040
+ seen.add(key);
1041
+ result.push(item);
1042
+ }
1043
+ return result;
1044
+ }
1045
+
1046
+ /** Pinned entries first, then explicitly ordered items, then unknown items by recency. */
1047
+ private get ungroupedItems(): TListItem[] {
1048
+ const grouped = this.groupedItemKeys;
1049
+ const available = this.acceptedItems.filter((item) => !grouped.has(item.key));
1050
+ const pinned = available.filter((item) => !item.sortable);
1051
+ const sortableByKey = new Map(
1052
+ available.filter((item) => item.sortable).map((item) => [item.key, item]),
1053
+ );
1054
+ const ordered: TListItem[] = [];
1055
+ for (const ref of this.ungroupedItemIds) {
1056
+ const item = sortableByKey.get(itemKey(ref));
1057
+ if (!item) continue;
1058
+ ordered.push(item);
1059
+ sortableByKey.delete(item.key);
1060
+ }
1061
+ const unknown = [...sortableByKey.values()].sort(
1062
+ (left, right) => right.timestamp - left.timestamp,
1063
+ );
1064
+ return [...pinned, ...ordered, ...unknown];
1065
+ }
1066
+
994
1067
  private resourceMatchesSearch(resourceArg: IHarnessResourceMeta, queryArg: string): boolean {
995
- return [resourceArg.id, resourceArg.kind, resourceArg.title, resourceArg.preview]
1068
+ return [resourceArg.id, resourceArg.kind, resourceArg.title, resourceArg.preview,
1069
+ resourceArg.projectLabel]
996
1070
  .filter(Boolean)
997
1071
  .join(' ')
998
1072
  .toLowerCase()
@@ -1001,78 +1075,53 @@ export class DeesHarnessSessionList extends DeesElement {
1001
1075
 
1002
1076
  private sessionMatchesSearch(sessionArg: IHarnessSessionMeta, queryArg: string): boolean {
1003
1077
  return [sessionArg.title, sessionArg.preview, sessionArg.id, sessionArg.harness?.id,
1004
- sessionArg.harness?.name, this.sessionStatusLabel(sessionArg), this.sessionStateLabel(sessionArg.state ?? 'normal')]
1078
+ sessionArg.harness?.name, sessionArg.projectLabel,
1079
+ this.sessionStatusLabel(sessionArg), this.sessionStateLabel(sessionArg.state ?? 'normal')]
1005
1080
  .filter(Boolean)
1006
1081
  .join(' ')
1007
1082
  .toLowerCase()
1008
1083
  .includes(queryArg);
1009
1084
  }
1010
1085
 
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));
1086
+ private itemMatchesSearch(itemArg: TListItem, queryArg: string): boolean {
1087
+ return itemArg.kind === 'session'
1088
+ ? this.sessionMatchesSearch(itemArg.session, queryArg)
1089
+ : this.resourceMatchesSearch(itemArg.resource, queryArg);
1019
1090
  }
1020
1091
 
1021
- private get unattachedResources(): IHarnessResourceMeta[] {
1022
- const associations = this.acceptedResourceAssociations;
1092
+ /** Search flattens the list: sessions and resources match as peers, newest first. */
1093
+ private get searchItems(): TListItem[] {
1023
1094
  const query = this.search.trim().toLowerCase();
1024
- return this.acceptedResources.filter((resource) => (
1025
- !associations.has(resource.id)
1026
- && (!query || this.resourceMatchesSearch(resource, query))
1027
- ));
1095
+ const sorted = [...this.acceptedItems].sort(
1096
+ (left, right) => right.timestamp - left.timestamp,
1097
+ );
1098
+ if (!query) return sorted;
1099
+ return sorted.filter((item) => this.itemMatchesSearch(item, query));
1028
1100
  }
1029
1101
 
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;
1102
+ private get searchActive(): boolean {
1103
+ return this.search.trim().length > 0;
1036
1104
  }
1037
1105
 
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];
1106
+ private sortableItems(itemsArg: TListItem[], excludeDraggingArg = true): TListItem[] {
1107
+ return itemsArg.filter((item) => (
1108
+ item.sortable
1109
+ && (!excludeDraggingArg || !sameItem(this.draggingItem, item))
1110
+ ));
1058
1111
  }
1059
1112
 
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
- );
1113
+ private groupIdForItem(refArg: IHarnessSessionListItemRef): string | null {
1114
+ return this.effectiveGroups.find(
1115
+ (group) => group.itemIds.some((candidate) => sameItem(candidate, refArg)),
1116
+ )?.id ?? null;
1117
+ }
1118
+
1119
+ private rowElementFor(refArg: IHarnessSessionListItemRef): HTMLElement | undefined {
1120
+ return Array.from(
1121
+ this.shadowRoot?.querySelectorAll<HTMLElement>('[data-item-kind][data-item-id]') ?? [],
1122
+ ).find((candidate) => (
1123
+ candidate.dataset.itemKind === refArg.kind && candidate.dataset.itemId === refArg.id
1124
+ ));
1076
1125
  }
1077
1126
 
1078
1127
  public focusSearch(): void {
@@ -1081,46 +1130,51 @@ export class DeesHarnessSessionList extends DeesElement {
1081
1130
 
1082
1131
  public requestResourceAssociation(
1083
1132
  resourceIdArg: string,
1084
- toSessionIdArg: string | null,
1133
+ toTargetArg: IHarnessSessionListItemRef | null,
1085
1134
  sourceArg: THarnessResourceAssociationInteractionSource = 'action',
1086
1135
  ): boolean {
1087
1136
  const resource = this.resourcesById.get(resourceIdArg);
1088
1137
  if (!resource) return false;
1089
- const fromSessionId = this.acceptedResourceAssociations.get(resourceIdArg) ?? null;
1090
- if (fromSessionId === toSessionIdArg) return false;
1138
+ const fromTarget = this.acceptedResourceAssociations.get(resourceIdArg) ?? null;
1139
+ if (sameItem(fromTarget, toTargetArg)) return false;
1091
1140
  if (
1092
- toSessionIdArg !== null
1093
- && (!this.sessionsById.has(toSessionIdArg) || !this.isResourceTargetEligible(resourceIdArg, toSessionIdArg))
1141
+ toTargetArg !== null
1142
+ && (
1143
+ !this.itemsByKey.has(itemKey(toTargetArg))
1144
+ || sameItem(toTargetArg, { kind: 'resource', id: resourceIdArg })
1145
+ || !this.isResourceTargetEligible(resourceIdArg, toTargetArg)
1146
+ )
1094
1147
  ) return false;
1095
- if (toSessionIdArg === null && fromSessionId === null) return false;
1096
- const detail: THarnessResourceAssociationRequestDetail = fromSessionId === null
1148
+ if (toTargetArg === null && fromTarget === null) return false;
1149
+ const toTarget = toTargetArg === null ? null : itemRef(toTargetArg);
1150
+ const detail: THarnessResourceAssociationRequestDetail = fromTarget === null
1097
1151
  ? {
1098
1152
  intent: 'attach',
1099
1153
  resource,
1100
- fromSessionId: null,
1101
- toSessionId: toSessionIdArg!,
1154
+ fromTarget: null,
1155
+ toTarget: toTarget!,
1102
1156
  source: sourceArg,
1103
1157
  }
1104
- : toSessionIdArg === null
1158
+ : toTarget === null
1105
1159
  ? {
1106
1160
  intent: 'detach',
1107
1161
  resource,
1108
- fromSessionId,
1109
- toSessionId: null,
1162
+ fromTarget,
1163
+ toTarget: null,
1110
1164
  source: sourceArg,
1111
1165
  }
1112
1166
  : {
1113
1167
  intent: 'reassign',
1114
1168
  resource,
1115
- fromSessionId,
1116
- toSessionId: toSessionIdArg,
1169
+ fromTarget,
1170
+ toTarget,
1117
1171
  source: sourceArg,
1118
1172
  };
1119
1173
  this.pendingResourceAssociationRequest = {
1120
1174
  resourceId: resource.id,
1121
1175
  resourceTitle: resource.title || resource.id,
1122
- fromSessionId,
1123
- toSessionId: toSessionIdArg,
1176
+ fromTarget,
1177
+ toTarget,
1124
1178
  intent: detail.intent,
1125
1179
  };
1126
1180
  this.moveAnnouncement = this.resourceAssociationAnnouncement(detail, false);
@@ -1147,12 +1201,6 @@ export class DeesHarnessSessionList extends DeesElement {
1147
1201
  }
1148
1202
  if (changedProperties.has('sessions')) {
1149
1203
  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
1204
  const nextExpanded = new Set(
1157
1205
  [...this.expandedSessionIds].filter((sessionId) => knownIds.has(sessionId)),
1158
1206
  );
@@ -1164,13 +1212,6 @@ export class DeesHarnessSessionList extends DeesElement {
1164
1212
  }
1165
1213
  }
1166
1214
  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
1215
  if (
1175
1216
  this.resourceContextResourceId
1176
1217
  && !this.resourcesById.has(this.resourceContextResourceId)
@@ -1179,16 +1220,15 @@ export class DeesHarnessSessionList extends DeesElement {
1179
1220
  }
1180
1221
  if (
1181
1222
  changedProperties.has('sessions')
1182
- || changedProperties.has('eligibleSessionIdsByResourceId')
1223
+ || changedProperties.has('resources')
1224
+ || changedProperties.has('search')
1183
1225
  ) {
1226
+ // A drag is positional only: it survives association and eligibility
1227
+ // updates, but never outlives its own row.
1184
1228
  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);
1229
+ if (gesture && (this.searchActive || !this.itemsByKey.has(itemKey(gesture.item)))) {
1230
+ this.finishPointerDrag(false);
1231
+ }
1192
1232
  }
1193
1233
  }
1194
1234
 
@@ -1211,7 +1251,6 @@ export class DeesHarnessSessionList extends DeesElement {
1211
1251
  }
1212
1252
 
1213
1253
  public render(): TemplateResult {
1214
- const groupingActive = this.enableGrouping && !this.search.trim();
1215
1254
  return html`
1216
1255
  <div class="searchRow">
1217
1256
  <div class="searchField">
@@ -1249,15 +1288,15 @@ export class DeesHarnessSessionList extends DeesElement {
1249
1288
  <div class="listFrame">
1250
1289
  <div class="scrollShadow top ${this.topScrollShadowVisible ? 'visible' : ''}"></div>
1251
1290
  <div
1252
- class="list ${this.draggingSessionId || this.draggingResourceId ? 'dragActive' : ''}"
1291
+ class="list ${this.draggingItem ? 'dragActive' : ''}"
1253
1292
  @scroll=${this.syncScrollShadowState}
1254
1293
  >
1255
1294
  <div class="listContent">
1256
1295
  ${this.loading
1257
1296
  ? html`<div class="emptyNote">Loading conversations…</div>`
1258
- : groupingActive
1259
- ? this.renderGroupedList()
1260
- : this.renderFlatList()}
1297
+ : this.searchActive
1298
+ ? this.renderSearchList()
1299
+ : this.renderOrderedList()}
1261
1300
  </div>
1262
1301
  </div>
1263
1302
  <div class="scrollShadow bottom ${this.bottomScrollShadowVisible ? 'visible' : ''}"></div>
@@ -1266,84 +1305,33 @@ export class DeesHarnessSessionList extends DeesElement {
1266
1305
  `;
1267
1306
  }
1268
1307
 
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
- `;
1308
+ private renderEmptyNote(): TemplateResult {
1309
+ return html`<div class="emptyNote">
1310
+ ${this.acceptedResources.length
1311
+ ? this.sessions.length
1312
+ ? 'No conversations or resources match this search.'
1313
+ : 'No resources match this search.'
1314
+ : this.sessions.length
1315
+ ? 'No conversations match this search.'
1316
+ : this.emptyText}
1317
+ </div>`;
1293
1318
  }
1294
1319
 
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) {
1320
+ private renderSearchList(): TemplateResult {
1321
+ const items = this.searchItems;
1322
+ if (!items.length) return this.renderEmptyNote();
1323
+ return this.renderSection(items, null, false);
1324
+ }
1325
+
1326
+ private renderOrderedList(): TemplateResult {
1327
+ const groups = this.effectiveGroups;
1328
+ const ungrouped = this.ungroupedItems;
1329
+ if (!this.acceptedItems.length && !groups.length) {
1300
1330
  return html`<div class="emptyNote">${this.emptyText}</div>`;
1301
1331
  }
1302
1332
  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)
1333
+ ${groups.map((group) => this.renderGroup(group))}
1334
+ ${groups.length && (ungrouped.length || this.draggingItem)
1347
1335
  ? html`
1348
1336
  <button
1349
1337
  class="groupHeader"
@@ -1358,56 +1346,107 @@ export class DeesHarnessSessionList extends DeesElement {
1358
1346
  </button>
1359
1347
  `
1360
1348
  : ''}
1361
- ${this.renderSection(ungrouped, null)}
1362
- ${this.renderUnattachedResources(unattachedResources)}
1349
+ ${this.renderSection(ungrouped, null, true)}
1350
+ `;
1351
+ }
1352
+
1353
+ private renderGroup(groupArg: IHarnessSessionGroup): TemplateResult {
1354
+ const members = this.itemsForGroup(groupArg.id);
1355
+ const collapsed = this.collapsedGroupIds.has(groupArg.id);
1356
+ const attentionCount = members.filter((item) => (
1357
+ item.kind === 'session' && (item.session.state === 'attention' || item.session.state === 'error')
1358
+ )).length;
1359
+ const appendIndex = this.sortableItems(members).length;
1360
+ const isHeaderDrop = this.dropSlot?.groupId === groupArg.id
1361
+ && this.dropSlot.index === appendIndex
1362
+ && collapsed;
1363
+ return html`
1364
+ <button
1365
+ class="groupHeader ${collapsed ? 'collapsed' : ''} ${isHeaderDrop ? 'dropTarget' : ''}"
1366
+ data-drop-group-id=${groupArg.id}
1367
+ data-drop-index=${appendIndex}
1368
+ data-drop-kind="slot"
1369
+ type="button"
1370
+ @click=${() => this.toggleGroupCollapsed(groupArg.id)}
1371
+ @contextmenu=${(event: MouseEvent) => {
1372
+ event.preventDefault();
1373
+ const detail: IHarnessGroupContextDetail = {
1374
+ group: groupArg,
1375
+ clientX: event.clientX,
1376
+ clientY: event.clientY,
1377
+ originalEvent: event,
1378
+ };
1379
+ this.dispatchEvent(
1380
+ new CustomEvent('harness-group-context', { detail, bubbles: true, composed: true }),
1381
+ );
1382
+ }}
1383
+ >
1384
+ <dees-icon class="chevron" icon="lucide:ChevronDown" iconSize="12"></dees-icon>
1385
+ <span>${groupArg.name}</span>
1386
+ ${attentionCount ? html`<span class="groupAttention" aria-label=${`${attentionCount} need attention`}>
1387
+ <dees-icon aria-hidden="true" icon="lucide:CircleAlert" iconSize="11"></dees-icon>${attentionCount}
1388
+ </span>` : ''}
1389
+ <span class="groupCount">${members.length}</span>
1390
+ </button>
1391
+ ${collapsed ? '' : this.renderSection(members, groupArg.id, true)}
1363
1392
  `;
1364
1393
  }
1365
1394
 
1366
1395
  private renderSection(
1367
- sessions: IHarnessSessionMeta[],
1368
- groupId: string | null,
1396
+ itemsArg: TListItem[],
1397
+ groupIdArg: string | null,
1398
+ sortableArg: boolean,
1369
1399
  ): TemplateResult {
1370
- const slot = this.dropSlot;
1400
+ const slot = sortableArg ? this.dropSlot : undefined;
1371
1401
  const entries: TSectionRenderEntry[] = [];
1372
1402
  let logicalIndex = 0;
1373
- for (const session of sessions) {
1374
- const isDragging = session.id === this.draggingSessionId;
1375
- const sortable = session.draggable !== false;
1403
+ for (const item of itemsArg) {
1404
+ const isDragging = sameItem(this.draggingItem, item);
1405
+ const sortable = sortableArg && item.sortable;
1376
1406
  if (
1377
1407
  slot
1378
- && slot.groupId === groupId
1408
+ && slot.groupId === groupIdArg
1379
1409
  && slot.index === logicalIndex
1380
1410
  && !isDragging
1381
1411
  && sortable
1382
1412
  ) {
1383
- entries.push({ type: 'placeholder', key: `placeholder:${groupId ?? 'ungrouped'}` });
1413
+ entries.push({ type: 'placeholder', key: `placeholder:${groupIdArg ?? 'ungrouped'}` });
1384
1414
  }
1385
1415
  entries.push({
1386
- type: 'session',
1387
- key: `session:${session.id}`,
1388
- session,
1416
+ type: 'item',
1417
+ key: item.key,
1418
+ item,
1389
1419
  logicalIndex: sortable ? logicalIndex : -1,
1390
1420
  });
1391
1421
  if (sortable && !isDragging) logicalIndex += 1;
1392
1422
  }
1393
- if (slot && slot.groupId === groupId && slot.index === logicalIndex) {
1394
- entries.push({ type: 'placeholder', key: `placeholder:${groupId ?? 'ungrouped'}` });
1423
+ if (slot && slot.groupId === groupIdArg && slot.index === logicalIndex) {
1424
+ entries.push({ type: 'placeholder', key: `placeholder:${groupIdArg ?? 'ungrouped'}` });
1395
1425
  }
1396
1426
  const hasPlaceholder = entries.some((entry) => entry.type === 'placeholder');
1427
+ // Resolve the controlled associations once per section, not once per row.
1428
+ const associations = this.acceptedResourceAssociations;
1397
1429
  return html`
1398
- <div class="section" data-group-id=${groupId ?? ''}>
1430
+ <div class="section" data-group-id=${groupIdArg ?? ''}>
1399
1431
  ${repeat(
1400
1432
  entries,
1401
1433
  (entry) => entry.key,
1402
1434
  (entry) => entry.type === 'placeholder'
1403
- ? this.renderDropPlaceholder(groupId, slot?.index ?? 0)
1404
- : this.renderItem(entry.session, groupId, entry.logicalIndex),
1435
+ ? this.renderDropPlaceholder(groupIdArg, slot?.index ?? 0)
1436
+ : entry.item.kind === 'session'
1437
+ ? this.renderSessionItem(entry.item.session, groupIdArg, entry.logicalIndex)
1438
+ : this.renderResourceItem(
1439
+ entry.item.resource,
1440
+ groupIdArg,
1441
+ entry.logicalIndex,
1442
+ associations.get(entry.item.id) ?? null,
1443
+ ),
1405
1444
  )}
1406
- ${this.draggingSessionId && !hasPlaceholder && this.sortableSessions(sessions).length === 0
1445
+ ${this.draggingItem && sortableArg && !hasPlaceholder && this.sortableItems(itemsArg).length === 0
1407
1446
  ? html`
1408
1447
  <div
1409
1448
  class="emptyDropZone"
1410
- data-drop-group-id=${groupId ?? ''}
1449
+ data-drop-group-id=${groupIdArg ?? ''}
1411
1450
  data-drop-index="0"
1412
1451
  data-drop-kind="slot"
1413
1452
  ></div>
@@ -1424,46 +1463,73 @@ export class DeesHarnessSessionList extends DeesElement {
1424
1463
  data-drop-group-id=${groupId ?? ''}
1425
1464
  data-drop-index=${index}
1426
1465
  data-drop-kind="slot"
1427
- style=${`height: ${Math.max(46, this.draggedItemHeight)}px`}
1466
+ style=${`height: ${Math.max(24, this.draggedItemHeight)}px`}
1428
1467
  ></div>
1429
1468
  `;
1430
1469
  }
1431
1470
 
1432
- private renderItem(
1471
+ /** One grip for both kinds; only its size and extra class differ. */
1472
+ private renderDragHandle(
1473
+ refArg: IHarnessSessionListItemRef,
1474
+ titleArg: string,
1475
+ groupIdArg: string | null,
1476
+ logicalIndexArg: number,
1477
+ ): TemplateResult {
1478
+ const isResource = refArg.kind === 'resource';
1479
+ return html`
1480
+ <button
1481
+ class="dragHandle ${isResource ? 'resourceDragHandle' : ''}"
1482
+ type="button"
1483
+ title="Drag to reorder; use arrow, Home, or End keys for keyboard reordering"
1484
+ aria-label=${`Reorder ${titleArg}`}
1485
+ aria-keyshortcuts="ArrowUp ArrowDown Home End"
1486
+ @pointerdown=${(event: PointerEvent) => {
1487
+ this.handlePointerDown(event, refArg, groupIdArg, logicalIndexArg);
1488
+ }}
1489
+ @pointermove=${this.handlePointerMove}
1490
+ @pointerup=${this.handlePointerUp}
1491
+ @pointercancel=${this.handlePointerCancel}
1492
+ @lostpointercapture=${this.handleLostPointerCapture}
1493
+ @keydown=${(event: KeyboardEvent) => {
1494
+ this.handleKeyboardMove(event, refArg, titleArg, groupIdArg);
1495
+ }}
1496
+ >
1497
+ <dees-icon icon="lucide:GripVertical" iconSize=${isResource ? '13' : '14'}></dees-icon>
1498
+ </button>
1499
+ `;
1500
+ }
1501
+
1502
+ private renderSessionItem(
1433
1503
  session: IHarnessSessionMeta,
1434
1504
  groupId: string | null,
1435
1505
  index: number,
1436
1506
  ): TemplateResult {
1437
- const draggable = (
1438
- this.enableGrouping
1439
- && session.draggable !== false
1440
- && index >= 0
1441
- );
1507
+ const draggable = index >= 0;
1442
1508
  const expanded = this.expandedSessionIds.has(session.id);
1443
1509
  const state = session.state ?? 'normal';
1444
1510
  const statusLabel = this.sessionStatusLabel(session);
1445
1511
  const active = (state === 'normal' || state === 'working') && (session.working ?? state === 'working');
1446
1512
  const detailsId = `session-details-${session.id}`;
1447
1513
  const dropIndex = index < 0 ? 0 : index;
1448
- const resources = this.resourcesForSession(session);
1449
- const isResourceDropTarget = this.resourceDropTargetSessionId === session.id;
1514
+ const title = session.title || session.id;
1515
+ const projectLabel = session.projectLabel?.trim() ?? '';
1450
1516
  return html`
1451
1517
  <article
1452
1518
  class="item state-${state} ${active ? 'active' : ''} ${expanded ? 'expanded' : ''} ${
1453
1519
  session.id === this.selectedSessionId ? 'selected' : ''
1454
1520
  } ${
1455
- this.draggingSessionId === session.id ? 'dragging' : ''
1456
- } ${
1457
- isResourceDropTarget ? 'resourceDropTarget' : ''
1521
+ sameItem(this.draggingItem, { kind: 'session', id: session.id }) ? 'dragging' : ''
1458
1522
  }"
1459
1523
  data-session-id=${session.id}
1524
+ data-item-kind="session"
1525
+ data-item-id=${session.id}
1460
1526
  data-drop-group-id=${groupId ?? ''}
1461
1527
  data-drop-index=${dropIndex}
1462
1528
  data-drop-kind=${index < 0 ? 'slot' : 'card'}
1463
1529
  @dblclick=${(event: MouseEvent) => {
1464
1530
  if (this.eventTargetsExpandToggle(event) || this.eventTargetsDragHandle(event)
1465
1531
  || this.eventTargetsSessionActions(event)) return;
1466
- this.emit('harness-session-open', session);
1532
+ this.emitSession('harness-session-open', session);
1467
1533
  }}
1468
1534
  @contextmenu=${(event: MouseEvent) => {
1469
1535
  if (this.eventTargetsExpandToggle(event)) return;
@@ -1483,39 +1549,19 @@ export class DeesHarnessSessionList extends DeesElement {
1483
1549
  <div class="itemInner">
1484
1550
  <div class="itemHeader ${draggable ? 'draggable' : ''}">
1485
1551
  ${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
- `
1552
+ ? this.renderDragHandle({ kind: 'session', id: session.id }, title, groupId, index)
1507
1553
  : ''}
1508
1554
  <button
1509
1555
  class="itemPrimary"
1510
1556
  type="button"
1511
1557
  aria-keyshortcuts="ContextMenu Shift+F10"
1512
1558
  aria-current=${session.id === this.selectedSessionId ? 'true' : 'false'}
1513
- @click=${() => this.emit('harness-session-select', session)}
1559
+ @click=${() => this.emitSession('harness-session-select', session)}
1514
1560
  @keydown=${(event: KeyboardEvent) => {
1515
1561
  if (this.handleSessionContextKey(event)) return;
1516
1562
  if (event.key !== 'Enter') return;
1517
1563
  event.preventDefault();
1518
- this.emit('harness-session-open', session);
1564
+ this.emitSession('harness-session-open', session);
1519
1565
  }}
1520
1566
  >
1521
1567
  <span class="stateDot" aria-hidden="true">
@@ -1526,7 +1572,7 @@ export class DeesHarnessSessionList extends DeesElement {
1526
1572
  ${session.icon
1527
1573
  ? html`<dees-icon class="itemIcon" icon=${session.icon} iconSize="13"></dees-icon>`
1528
1574
  : ''}
1529
- <span class="titleText">${session.title || session.id}</span>
1575
+ <span class="titleText">${title}</span>
1530
1576
  </span>
1531
1577
  <span class="itemStatus" title=${statusLabel}>${statusLabel}</span>
1532
1578
  <span class="itemIdentity">
@@ -1535,6 +1581,10 @@ export class DeesHarnessSessionList extends DeesElement {
1535
1581
  <span class="harnessName" title=${session.harness.name}>${session.harness.name}</span>
1536
1582
  <span aria-hidden="true">·</span>
1537
1583
  ` : ''}
1584
+ ${projectLabel ? html`
1585
+ <span class="projectName" title=${projectLabel}>${projectLabel}</span>
1586
+ <span aria-hidden="true">·</span>
1587
+ ` : ''}
1538
1588
  <span class="itemTime">${harnessFormatRelativeTime(session.updatedAt ?? session.createdAt)}</span>
1539
1589
  </span>
1540
1590
  </span>
@@ -1543,7 +1593,7 @@ export class DeesHarnessSessionList extends DeesElement {
1543
1593
  class="sessionActions"
1544
1594
  type="button"
1545
1595
  title="Actions"
1546
- aria-label=${`Actions for ${session.title || session.id}`}
1596
+ aria-label=${`Actions for ${title}`}
1547
1597
  aria-haspopup="menu"
1548
1598
  aria-keyshortcuts="ContextMenu Shift+F10"
1549
1599
  @click=${this.invokeSessionContext}
@@ -1565,20 +1615,6 @@ export class DeesHarnessSessionList extends DeesElement {
1565
1615
  <dees-icon icon="lucide:ChevronDown" iconSize="15"></dees-icon>
1566
1616
  </button>
1567
1617
  </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
1618
  ${expanded
1583
1619
  ? html`
1584
1620
  <div class="itemDetails" id=${detailsId}>
@@ -1594,41 +1630,36 @@ export class DeesHarnessSessionList extends DeesElement {
1594
1630
  `;
1595
1631
  }
1596
1632
 
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(
1633
+ private renderResourceItem(
1614
1634
  resourceArg: IHarnessResourceMeta,
1615
- sessionIdArg: string | null,
1635
+ groupIdArg: string | null,
1636
+ indexArg: number,
1637
+ attachedTarget: IHarnessSessionListItemRef | null,
1616
1638
  ): TemplateResult {
1617
1639
  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;
1640
+ const draggable = indexArg >= 0;
1641
+ const dragging = sameItem(this.draggingItem, { kind: 'resource', id: resourceArg.id });
1622
1642
  const contextOpen = resourceArg.id === this.resourceContextResourceId;
1643
+ const attachedTitle = attachedTarget ? this.itemTitleFor(attachedTarget) : '';
1644
+ const title = resourceArg.title || resourceArg.id;
1645
+ const projectLabel = resourceArg.projectLabel?.trim() ?? '';
1646
+ const dropIndex = indexArg < 0 ? 0 : indexArg;
1623
1647
  return html`
1624
- <li
1625
- class="resourceRow ${selected ? 'selected' : ''} ${dragging ? 'resourceDragging' : ''}"
1648
+ <article
1649
+ class="resourceRow ${selected ? 'selected' : ''} ${draggable ? 'draggable' : ''} ${
1650
+ dragging ? 'dragging' : ''
1651
+ }"
1626
1652
  data-resource-id=${resourceArg.id}
1627
- data-resource-session-id=${sessionIdArg ?? ''}
1653
+ data-item-kind="resource"
1654
+ data-item-id=${resourceArg.id}
1655
+ data-resource-target=${attachedTarget ? `${attachedTarget.kind}:${attachedTarget.id}` : ''}
1656
+ data-drop-group-id=${groupIdArg ?? ''}
1657
+ data-drop-index=${dropIndex}
1658
+ data-drop-kind=${indexArg < 0 ? 'slot' : 'card'}
1628
1659
  @dblclick=${(event: MouseEvent) => {
1629
1660
  event.stopPropagation();
1630
- if (this.eventTargetsResourceControl(event)) return;
1631
- this.emitResource('harness-resource-open', resourceArg, sessionIdArg);
1661
+ if (this.eventTargetsResourceControl(event) || this.eventTargetsDragHandle(event)) return;
1662
+ this.emitResource('harness-resource-open', resourceArg, attachedTarget);
1632
1663
  }}
1633
1664
  @contextmenu=${(event: MouseEvent) => {
1634
1665
  event.preventDefault();
@@ -1639,43 +1670,29 @@ export class DeesHarnessSessionList extends DeesElement {
1639
1670
  )) as HTMLElement | undefined)
1640
1671
  ?? (event.currentTarget as HTMLElement).querySelector<HTMLElement>('.resourcePrimary')
1641
1672
  ?? event.currentTarget as HTMLElement;
1642
- this.emitResourceContext(event, resourceArg, sessionIdArg, 'pointer', focusTarget);
1673
+ this.emitResourceContext(event, resourceArg, attachedTarget, 'pointer', focusTarget);
1643
1674
  }}
1644
1675
  >
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>
1676
+ ${draggable
1677
+ ? this.renderDragHandle(
1678
+ { kind: 'resource', id: resourceArg.id },
1679
+ title,
1680
+ groupIdArg,
1681
+ indexArg,
1682
+ )
1683
+ : ''}
1667
1684
  <button
1668
1685
  class="resourcePrimary"
1669
1686
  type="button"
1670
1687
  aria-current=${selected ? 'true' : 'false'}
1671
- @click=${() => this.emitResource('harness-resource-select', resourceArg, sessionIdArg)}
1688
+ @click=${() => this.emitResource('harness-resource-select', resourceArg, attachedTarget)}
1672
1689
  @keydown=${(event: KeyboardEvent) => {
1673
1690
  if (event.key === 'Enter') {
1674
1691
  event.preventDefault();
1675
- this.emitResource('harness-resource-open', resourceArg, sessionIdArg);
1692
+ this.emitResource('harness-resource-open', resourceArg, attachedTarget);
1676
1693
  return;
1677
1694
  }
1678
- this.handleResourceContextKey(event, resourceArg, sessionIdArg);
1695
+ this.handleResourceContextKey(event, resourceArg, attachedTarget);
1679
1696
  }}
1680
1697
  >
1681
1698
  <dees-icon
@@ -1684,36 +1701,53 @@ export class DeesHarnessSessionList extends DeesElement {
1684
1701
  iconSize="14"
1685
1702
  ></dees-icon>
1686
1703
  <span class="resourceCopy">
1687
- <span class="resourceTitle">${resourceArg.title || resourceArg.id}</span>
1704
+ <span class="resourceTitle">${title}</span>
1688
1705
  ${resourceArg.preview ? html`<span class="resourcePreview">${resourceArg.preview}</span>` : ''}
1689
1706
  <span class="resourceMeta">
1690
- ${resourceArg.kind}${resourceArg.state ? ` · ${resourceArg.state}` : ''}${
1707
+ ${resourceArg.kind}${projectLabel ? ` · ${projectLabel}` : ''}${
1708
+ resourceArg.state ? ` · ${resourceArg.state}` : ''
1709
+ }${
1691
1710
  resourceArg.updatedAt || resourceArg.createdAt
1692
1711
  ? ` · ${harnessFormatRelativeTime(resourceArg.updatedAt ?? resourceArg.createdAt)}`
1693
1712
  : ''
1694
1713
  }
1695
1714
  </span>
1715
+ ${attachedTarget
1716
+ ? html`
1717
+ <span class="resourceAttachment" title=${`Attached to ${attachedTitle}`}>
1718
+ <dees-icon aria-hidden="true" icon="lucide:Link" iconSize="10"></dees-icon>
1719
+ <span class="visuallyHidden">Attached to </span>
1720
+ <span class="resourceAttachmentName">${attachedTitle}</span>
1721
+ </span>
1722
+ `
1723
+ : ''}
1696
1724
  </span>
1697
1725
  </button>
1698
1726
  <button
1699
1727
  class="resourceActions"
1700
1728
  type="button"
1701
1729
  title="Resource actions"
1702
- aria-label=${`Actions for ${resourceArg.title || resourceArg.id}`}
1730
+ aria-label=${`Actions for ${title}`}
1703
1731
  aria-haspopup="menu"
1704
1732
  aria-expanded=${contextOpen ? 'true' : 'false'}
1705
1733
  aria-keyshortcuts="ContextMenu Shift+F10"
1706
1734
  @click=${(event: MouseEvent) => {
1707
1735
  event.stopPropagation();
1708
- this.emitResourceContext(event, resourceArg, sessionIdArg, 'action', event.currentTarget as HTMLElement);
1736
+ this.emitResourceContext(
1737
+ event,
1738
+ resourceArg,
1739
+ attachedTarget,
1740
+ 'action',
1741
+ event.currentTarget as HTMLElement,
1742
+ );
1709
1743
  }}
1710
1744
  @keydown=${(event: KeyboardEvent) => {
1711
- this.handleResourceContextKey(event, resourceArg, sessionIdArg);
1745
+ this.handleResourceContextKey(event, resourceArg, attachedTarget);
1712
1746
  }}
1713
1747
  >
1714
1748
  <dees-icon icon="lucide:Ellipsis" iconSize="15"></dees-icon>
1715
1749
  </button>
1716
- </li>
1750
+ </article>
1717
1751
  `;
1718
1752
  }
1719
1753
 
@@ -1755,10 +1789,7 @@ export class DeesHarnessSessionList extends DeesElement {
1755
1789
 
1756
1790
  private eventTargetsResourceControl(event: Event): boolean {
1757
1791
  return event.composedPath().some(
1758
- (target) => target instanceof HTMLElement && (
1759
- target.classList.contains('resourceDragHandle')
1760
- || target.classList.contains('resourceActions')
1761
- ),
1792
+ (target) => target instanceof HTMLElement && target.classList.contains('resourceActions'),
1762
1793
  );
1763
1794
  }
1764
1795
 
@@ -1803,29 +1834,9 @@ export class DeesHarnessSessionList extends DeesElement {
1803
1834
  this.collapsedGroupIds = next;
1804
1835
  }
1805
1836
 
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
1837
  private handlePointerDown(
1827
1838
  eventArg: PointerEvent,
1828
- sessionArg: IHarnessSessionMeta,
1839
+ refArg: IHarnessSessionListItemRef,
1829
1840
  groupIdArg: string | null,
1830
1841
  logicalIndexArg: number,
1831
1842
  ): void {
@@ -1836,15 +1847,14 @@ export class DeesHarnessSessionList extends DeesElement {
1836
1847
  || logicalIndexArg < 0
1837
1848
  ) return;
1838
1849
  const handle = eventArg.currentTarget as HTMLElement;
1839
- const source = handle.closest<HTMLElement>('.item');
1850
+ const source = handle.closest<HTMLElement>('.item, .resourceRow');
1840
1851
  if (!source) return;
1841
1852
  const sourceRect = source.getBoundingClientRect();
1842
1853
  this.pointerDragGesture = {
1843
- type: 'session',
1844
1854
  pointerId: eventArg.pointerId,
1845
1855
  handle,
1846
1856
  source,
1847
- sessionId: sessionArg.id,
1857
+ item: itemRef(refArg),
1848
1858
  sourceGroupId: groupIdArg,
1849
1859
  sourceLogicalIndex: logicalIndexArg,
1850
1860
  startClientX: eventArg.clientX,
@@ -1862,44 +1872,6 @@ export class DeesHarnessSessionList extends DeesElement {
1862
1872
  }
1863
1873
  }
1864
1874
 
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
1875
  private readonly handlePointerMove = (eventArg: PointerEvent): void => {
1904
1876
  const gesture = this.pointerDragGesture;
1905
1877
  if (!gesture || eventArg.pointerId !== gesture.pointerId) return;
@@ -1914,21 +1886,8 @@ export class DeesHarnessSessionList extends DeesElement {
1914
1886
  eventArg.clientY - gesture.startClientY,
1915
1887
  ) < dragThresholdPixels
1916
1888
  ) return;
1917
- if (
1918
- gesture.phase === 'pending'
1919
- && !(gesture.type === 'session'
1920
- ? this.startPointerDrag(eventArg, gesture)
1921
- : this.startResourcePointerDrag(eventArg, gesture))
1922
- ) return;
1889
+ if (gesture.phase === 'pending' && !this.startPointerDrag(eventArg, gesture)) return;
1923
1890
  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
1891
  this.moveDragPreview(eventArg.clientX, eventArg.clientY, gesture);
1933
1892
  this.dragAutoScrollClientX = eventArg.clientX;
1934
1893
  this.dragAutoScrollClientY = eventArg.clientY;
@@ -1940,14 +1899,6 @@ export class DeesHarnessSessionList extends DeesElement {
1940
1899
  const gesture = this.pointerDragGesture;
1941
1900
  if (!gesture || eventArg.pointerId !== gesture.pointerId) return;
1942
1901
  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
1902
  this.dragAutoScrollClientX = eventArg.clientX;
1952
1903
  this.dragAutoScrollClientY = eventArg.clientY;
1953
1904
  this.moveDragPreview(eventArg.clientX, eventArg.clientY, gesture);
@@ -1993,9 +1944,9 @@ export class DeesHarnessSessionList extends DeesElement {
1993
1944
  gestureArg.phase = 'active';
1994
1945
  this.reflowGeneration += 1;
1995
1946
  this.cancelReflowAnimations();
1996
- this.draggedItemHeight = Math.ceil(sourceRect.height);
1997
- this.draggingSessionId = gestureArg.sessionId;
1998
- const remainingCount = this.sortableSessions(this.sessionsForGroup(gestureArg.sourceGroupId)).length;
1947
+ this.draggedItemHeight = sourceRect.height;
1948
+ this.draggingItem = itemRef(gestureArg.item);
1949
+ const remainingCount = this.sortableItems(this.itemsForGroup(gestureArg.sourceGroupId)).length;
1999
1950
  this.dropSlot = {
2000
1951
  groupId: gestureArg.sourceGroupId,
2001
1952
  index: Math.min(gestureArg.sourceLogicalIndex, remainingCount),
@@ -2003,27 +1954,6 @@ export class DeesHarnessSessionList extends DeesElement {
2003
1954
  return true;
2004
1955
  }
2005
1956
 
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
1957
  private moveDragPreview(
2028
1958
  clientXArg: number,
2029
1959
  clientYArg: number,
@@ -2039,49 +1969,21 @@ export class DeesHarnessSessionList extends DeesElement {
2039
1969
  );
2040
1970
  }
2041
1971
 
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
1972
  private setDropSlot(groupIdArg: string | null, indexArg: number): void {
2071
1973
  const current = this.dropSlot;
2072
1974
  if (current && current.groupId === groupIdArg && current.index === indexArg) return;
2073
- const previousRects = this.captureCardRects();
1975
+ const previousRects = this.captureRowRects();
2074
1976
  const generation = ++this.reflowGeneration;
2075
1977
  this.dropSlot = { groupId: groupIdArg, index: indexArg };
2076
- void this.animateCardReflow(previousRects, generation);
1978
+ void this.animateRowReflow(previousRects, generation);
2077
1979
  }
2078
1980
 
2079
1981
  private clearDropSlot(): void {
2080
1982
  if (!this.dropSlot) return;
2081
- const previousRects = this.captureCardRects();
1983
+ const previousRects = this.captureRowRects();
2082
1984
  const generation = ++this.reflowGeneration;
2083
1985
  this.dropSlot = undefined;
2084
- void this.animateCardReflow(previousRects, generation);
1986
+ void this.animateRowReflow(previousRects, generation);
2085
1987
  }
2086
1988
 
2087
1989
  private updateDragAutoScrollVelocity(): void {
@@ -2134,14 +2036,7 @@ export class DeesHarnessSessionList extends DeesElement {
2134
2036
  this.stopDragAutoScroll();
2135
2037
  return;
2136
2038
  }
2137
- if (gesture.type === 'resource') {
2138
- this.updateResourceDropTargetFromPointer(
2139
- this.dragAutoScrollClientX,
2140
- this.dragAutoScrollClientY,
2141
- );
2142
- } else {
2143
- this.updateDropSlotFromPointer();
2144
- }
2039
+ this.updateDropSlotFromPointer();
2145
2040
  this.dragAutoScrollFrame = requestAnimationFrame(this.runDragAutoScroll);
2146
2041
  };
2147
2042
 
@@ -2165,6 +2060,7 @@ export class DeesHarnessSessionList extends DeesElement {
2165
2060
  }
2166
2061
  }
2167
2062
  const selector = '[data-drop-group-id][data-drop-index][data-drop-kind]';
2063
+ const dragging = this.draggingItem;
2168
2064
  let target: HTMLElement | undefined;
2169
2065
  const offsets = this.dragAutoScrollVelocity === 0
2170
2066
  ? [0, -8, 8, -16, 16, -32, 32]
@@ -2176,7 +2072,11 @@ export class DeesHarnessSessionList extends DeesElement {
2176
2072
  const candidate = hit?.closest<HTMLElement>(selector);
2177
2073
  if (
2178
2074
  candidate
2179
- && candidate.dataset.sessionId !== this.draggingSessionId
2075
+ && !(
2076
+ dragging
2077
+ && candidate.dataset.itemKind === dragging.kind
2078
+ && candidate.dataset.itemId === dragging.id
2079
+ )
2180
2080
  && Number(candidate.dataset.dropIndex) >= 0
2181
2081
  ) {
2182
2082
  target = candidate;
@@ -2235,70 +2135,69 @@ export class DeesHarnessSessionList extends DeesElement {
2235
2135
  }
2236
2136
 
2237
2137
  private createMoveDetail(
2238
- sessionIdArg: string,
2239
- groupIdArg: string | null,
2138
+ refArg: IHarnessSessionListItemRef,
2139
+ toGroupIdArg: string | null,
2240
2140
  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;
2141
+ sourceArg: THarnessItemMoveSource,
2142
+ ): IHarnessItemMoveDetail {
2143
+ const targetItems = this.sortableItems(this.itemsForGroup(toGroupIdArg));
2144
+ const beforeItem = targetItems[Math.max(0, indexArg)];
2252
2145
  return {
2253
- sessionId: sessionIdArg,
2254
- groupId: groupIdArg,
2255
- index: Math.max(0, compatibilityIndex),
2256
- ...(beforeSessionId ? { beforeSessionId } : {}),
2146
+ item: itemRef(refArg),
2147
+ fromGroupId: this.groupIdForItem(refArg),
2148
+ toGroupId: toGroupIdArg,
2149
+ beforeItem: beforeItem ? itemRef(beforeItem) : null,
2150
+ source: sourceArg,
2257
2151
  };
2258
2152
  }
2259
2153
 
2154
+ private emitItemMove(detailArg: IHarnessItemMoveDetail): void {
2155
+ this.dispatchEvent(
2156
+ new CustomEvent('harness-item-move', { detail: detailArg, bubbles: true, composed: true }),
2157
+ );
2158
+ }
2159
+
2260
2160
  private handleKeyboardMove(
2261
2161
  eventArg: KeyboardEvent,
2262
- sessionArg: IHarnessSessionMeta,
2162
+ refArg: IHarnessSessionListItemRef,
2163
+ titleArg: string,
2263
2164
  groupIdArg: string | null,
2264
2165
  ): void {
2265
2166
  if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(eventArg.key)) return;
2266
2167
  eventArg.preventDefault();
2267
2168
  eventArg.stopPropagation();
2268
- const sessions = this.sortableSessions(this.sessionsForGroup(groupIdArg), false);
2269
- const sourceIndex = sessions.findIndex((session) => session.id === sessionArg.id);
2169
+ const items = this.sortableItems(this.itemsForGroup(groupIdArg), false);
2170
+ const sourceIndex = items.findIndex((item) => sameItem(item, refArg));
2270
2171
  if (sourceIndex < 0) return;
2271
- const sectionIds: Array<string | null> = [...this.groups.map((group) => group.id), null];
2172
+ const sectionIds: Array<string | null> = [...this.effectiveGroups.map((group) => group.id), null];
2272
2173
  const sectionIndex = sectionIds.indexOf(groupIdArg);
2273
2174
  const adjacentGroupId = eventArg.key === 'ArrowUp' && sourceIndex === 0 && sectionIndex > 0
2274
2175
  ? sectionIds[sectionIndex - 1]
2275
2176
  : eventArg.key === 'ArrowDown'
2276
- && sourceIndex === sessions.length - 1
2177
+ && sourceIndex === items.length - 1
2277
2178
  && sectionIndex >= 0
2278
2179
  && sectionIndex < sectionIds.length - 1
2279
2180
  ? sectionIds[sectionIndex + 1]
2280
2181
  : undefined;
2281
2182
  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
- };
2183
+ const targetItems = this.sortableItems(this.itemsForGroup(adjacentGroupId), false);
2184
+ const targetIndex = eventArg.key === 'ArrowUp' ? targetItems.length : 0;
2185
+ const beforeItem = targetItems[targetIndex];
2291
2186
  const groupName = adjacentGroupId === null
2292
2187
  ? '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
- );
2188
+ : this.effectiveGroups.find((group) => group.id === adjacentGroupId)?.name ?? adjacentGroupId;
2189
+ this.moveAnnouncement = `Moved ${titleArg} to ${groupName}.`;
2190
+ this.keyboardMoveFocus = { item: itemRef(refArg), groupId: adjacentGroupId };
2191
+ this.emitItemMove({
2192
+ item: itemRef(refArg),
2193
+ fromGroupId: groupIdArg,
2194
+ toGroupId: adjacentGroupId,
2195
+ beforeItem: beforeItem ? itemRef(beforeItem) : null,
2196
+ source: 'keyboard',
2197
+ });
2299
2198
  return;
2300
2199
  }
2301
- const remaining = sessions.filter((session) => session.id !== sessionArg.id);
2200
+ const remaining = items.filter((item) => !sameItem(item, refArg));
2302
2201
  const targetIndex = eventArg.key === 'Home'
2303
2202
  ? 0
2304
2203
  : eventArg.key === 'End'
@@ -2307,27 +2206,30 @@ export class DeesHarnessSessionList extends DeesElement {
2307
2206
  ? Math.max(0, sourceIndex - 1)
2308
2207
  : Math.min(remaining.length, sourceIndex + 1);
2309
2208
  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
- );
2209
+ const beforeItem = remaining[targetIndex];
2210
+ this.moveAnnouncement = beforeItem
2211
+ ? `Moved ${titleArg} before ${beforeItem.title}.`
2212
+ : `Moved ${titleArg} to the end.`;
2213
+ this.emitItemMove({
2214
+ item: itemRef(refArg),
2215
+ fromGroupId: groupIdArg,
2216
+ toGroupId: groupIdArg,
2217
+ beforeItem: beforeItem ? itemRef(beforeItem) : null,
2218
+ source: 'keyboard',
2219
+ });
2220
+ }
2221
+
2222
+ private rowKey(rowArg: HTMLElement): string {
2223
+ return `${rowArg.dataset.itemKind}:${rowArg.dataset.itemId}`;
2324
2224
  }
2325
2225
 
2326
- private captureCardRects(): Map<string, DOMRect> {
2226
+ private captureRowRects(): Map<string, DOMRect> {
2327
2227
  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());
2228
+ const draggingKey = this.draggingItem ? itemKey(this.draggingItem) : '';
2229
+ for (const row of this.shadowRoot?.querySelectorAll<HTMLElement>('[data-item-kind][data-item-id]') ?? []) {
2230
+ const key = this.rowKey(row);
2231
+ if (key === draggingKey) continue;
2232
+ result.set(key, row.getBoundingClientRect());
2331
2233
  }
2332
2234
  return result;
2333
2235
  }
@@ -2335,14 +2237,8 @@ export class DeesHarnessSessionList extends DeesElement {
2335
2237
  private restoreKeyboardMoveFocus(): void {
2336
2238
  const pending = this.keyboardMoveFocus;
2337
2239
  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');
2240
+ if (this.groupIdForItem(pending.item) !== pending.groupId) return;
2241
+ const handle = this.rowElementFor(pending.item)?.querySelector<HTMLElement>('.dragHandle');
2346
2242
  if (handle) {
2347
2243
  handle.focus();
2348
2244
  this.keyboardMoveFocus = undefined;
@@ -2357,35 +2253,52 @@ export class DeesHarnessSessionList extends DeesElement {
2357
2253
  }
2358
2254
  }
2359
2255
 
2360
- private eligibleSessionIds(resourceIdArg: string): string[] {
2361
- const knownSessionIds = new Set(this.sessions.map((session) => session.id));
2362
- const result: string[] = [];
2256
+ /** Known, de-duplicated targets the host allows; an absent entry means unrestricted. */
2257
+ private eligibleTargets(resourceIdArg: string): IHarnessSessionListItemRef[] | undefined {
2258
+ const declared = this.eligibleTargetsByResourceId[resourceIdArg];
2259
+ if (!declared) return undefined;
2260
+ const itemsByKey = this.itemsByKey;
2261
+ const result: IHarnessSessionListItemRef[] = [];
2363
2262
  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);
2263
+ for (const target of declared) {
2264
+ const key = target && itemKey(target);
2265
+ if (
2266
+ !key
2267
+ || seen.has(key)
2268
+ || !itemsByKey.has(key)
2269
+ || sameItem(target, { kind: 'resource', id: resourceIdArg })
2270
+ ) continue;
2271
+ seen.add(key);
2272
+ result.push(itemRef(target));
2368
2273
  }
2369
2274
  return result;
2370
2275
  }
2371
2276
 
2372
- private sameSessionIds(firstArg: ReadonlySet<string>, secondArg: ReadonlySet<string>): boolean {
2373
- return firstArg.size === secondArg.size
2374
- && [...firstArg].every((sessionId) => secondArg.has(sessionId));
2277
+ private isResourceTargetEligible(
2278
+ resourceIdArg: string,
2279
+ targetArg: IHarnessSessionListItemRef,
2280
+ ): boolean {
2281
+ const eligible = this.eligibleTargets(resourceIdArg);
2282
+ return eligible === undefined || eligible.some((candidate) => sameItem(candidate, targetArg));
2283
+ }
2284
+
2285
+ private itemTitleFor(refArg: IHarnessSessionListItemRef): string {
2286
+ return this.itemsByKey.get(itemKey(refArg))?.title ?? refArg.id;
2375
2287
  }
2376
2288
 
2377
- private isResourceTargetEligible(resourceIdArg: string, sessionIdArg: string): boolean {
2378
- return this.eligibleSessionIds(resourceIdArg).includes(sessionIdArg);
2289
+ private emitSession(nameArg: string, sessionArg: IHarnessSessionMeta): void {
2290
+ const detail: IHarnessSessionEventDetail = { session: sessionArg };
2291
+ this.dispatchEvent(new CustomEvent(nameArg, { detail, bubbles: true, composed: true }));
2379
2292
  }
2380
2293
 
2381
2294
  private emitResource(
2382
2295
  nameArg: string,
2383
2296
  resourceArg: IHarnessResourceMeta,
2384
- sessionIdArg: string | null,
2297
+ targetArg: IHarnessSessionListItemRef | null,
2385
2298
  ): void {
2386
2299
  const detail: IHarnessResourceEventDetail = {
2387
2300
  resource: resourceArg,
2388
- sessionId: sessionIdArg,
2301
+ target: targetArg,
2389
2302
  };
2390
2303
  this.dispatchEvent(new CustomEvent(nameArg, { detail, bubbles: true, composed: true }));
2391
2304
  }
@@ -2393,7 +2306,7 @@ export class DeesHarnessSessionList extends DeesElement {
2393
2306
  private handleResourceContextKey(
2394
2307
  eventArg: KeyboardEvent,
2395
2308
  resourceArg: IHarnessResourceMeta,
2396
- sessionIdArg: string | null,
2309
+ targetArg: IHarnessSessionListItemRef | null,
2397
2310
  ): void {
2398
2311
  if (!(eventArg.key === 'ContextMenu' || (eventArg.key === 'F10' && eventArg.shiftKey))) return;
2399
2312
  eventArg.preventDefault();
@@ -2401,7 +2314,7 @@ export class DeesHarnessSessionList extends DeesElement {
2401
2314
  this.emitResourceContext(
2402
2315
  eventArg,
2403
2316
  resourceArg,
2404
- sessionIdArg,
2317
+ targetArg,
2405
2318
  'keyboard',
2406
2319
  eventArg.currentTarget as HTMLElement,
2407
2320
  );
@@ -2410,7 +2323,7 @@ export class DeesHarnessSessionList extends DeesElement {
2410
2323
  private emitResourceContext(
2411
2324
  eventArg: MouseEvent | KeyboardEvent,
2412
2325
  resourceArg: IHarnessResourceMeta,
2413
- sessionIdArg: string | null,
2326
+ targetArg: IHarnessSessionListItemRef | null,
2414
2327
  sourceArg: THarnessResourceContextSource,
2415
2328
  focusTargetArg: HTMLElement,
2416
2329
  ): void {
@@ -2425,7 +2338,7 @@ export class DeesHarnessSessionList extends DeesElement {
2425
2338
  ].find((className) => focusTargetArg.classList.contains(className)) ?? 'resourcePrimary';
2426
2339
  const detail: IHarnessResourceContextDetail = {
2427
2340
  resource: resourceArg,
2428
- sessionId: sessionIdArg,
2341
+ target: targetArg,
2429
2342
  clientX: sourceArg === 'pointer' && eventArg instanceof MouseEvent
2430
2343
  ? eventArg.clientX
2431
2344
  : rect.left + rect.width / 2,
@@ -2434,9 +2347,9 @@ export class DeesHarnessSessionList extends DeesElement {
2434
2347
  : rect.bottom,
2435
2348
  source: sourceArg,
2436
2349
  originalEvent: eventArg,
2437
- requestAssociation: (toSessionIdArg) => this.requestResourceAssociation(
2350
+ requestAssociation: (toTargetArg) => this.requestResourceAssociation(
2438
2351
  resourceArg.id,
2439
- toSessionIdArg,
2352
+ toTargetArg,
2440
2353
  sourceArg === 'keyboard' ? 'keyboard' : 'action',
2441
2354
  ),
2442
2355
  close: () => this.closeResourceContext(contextGeneration),
@@ -2462,9 +2375,7 @@ export class DeesHarnessSessionList extends DeesElement {
2462
2375
  focusTarget.focus();
2463
2376
  return;
2464
2377
  }
2465
- const row = Array.from(
2466
- this.shadowRoot?.querySelectorAll<HTMLElement>('.resourceRow[data-resource-id]') ?? [],
2467
- ).find((candidate) => candidate.dataset.resourceId === resourceId);
2378
+ const row = this.rowElementFor({ kind: 'resource', id: resourceId });
2468
2379
  row?.querySelector<HTMLElement>(`.${focusClass || 'resourcePrimary'}`)?.focus();
2469
2380
  });
2470
2381
  }
@@ -2483,28 +2394,22 @@ export class DeesHarnessSessionList extends DeesElement {
2483
2394
  this.pendingResourceAssociationRequest = undefined;
2484
2395
  return;
2485
2396
  }
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}.`;
2397
+ const currentTarget = this.acceptedResourceAssociations.get(pending.resourceId) ?? null;
2398
+ if (!sameItem(currentTarget, pending.toTarget) && !(currentTarget === null && pending.toTarget === null)) {
2399
+ if (!sameItem(currentTarget, pending.fromTarget) && !(currentTarget === null && pending.fromTarget === null)) {
2400
+ const controlledTitle = currentTarget ? this.itemTitleFor(currentTarget) : 'unattached';
2401
+ this.moveAnnouncement = `Association request for ${pending.resourceTitle} was not applied; controlled association is ${controlledTitle}.`;
2493
2402
  this.pendingResourceAssociationRequest = undefined;
2494
2403
  }
2495
2404
  return;
2496
2405
  }
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
- : '';
2406
+ const fromTitle = pending.fromTarget ? this.itemTitleFor(pending.fromTarget) : '';
2407
+ const toTitle = pending.toTarget ? this.itemTitleFor(pending.toTarget) : '';
2503
2408
  this.moveAnnouncement = pending.intent === 'attach'
2504
- ? `${pending.resourceTitle} is now attached to ${toSession}.`
2409
+ ? `${pending.resourceTitle} is now attached to ${toTitle}.`
2505
2410
  : pending.intent === 'detach'
2506
- ? `${pending.resourceTitle} is now detached from ${fromSession}.`
2507
- : `${pending.resourceTitle} is now reassigned from ${fromSession} to ${toSession}.`;
2411
+ ? `${pending.resourceTitle} is now detached from ${fromTitle}.`
2412
+ : `${pending.resourceTitle} is now reassigned from ${fromTitle} to ${toTitle}.`;
2508
2413
  this.pendingResourceAssociationRequest = undefined;
2509
2414
  }
2510
2415
 
@@ -2513,12 +2418,8 @@ export class DeesHarnessSessionList extends DeesElement {
2513
2418
  completedArg: boolean,
2514
2419
  ): string {
2515
2420
  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
- : '';
2421
+ const fromTitle = detailArg.fromTarget ? this.itemTitleFor(detailArg.fromTarget) : '';
2422
+ const toTitle = detailArg.toTarget ? this.itemTitleFor(detailArg.toTarget) : '';
2522
2423
  const verb = completedArg ? 'completed' : 'requested';
2523
2424
  return detailArg.intent === 'attach'
2524
2425
  ? `Attach ${verb} for ${resourceTitle} to ${toTitle}.`
@@ -2527,7 +2428,7 @@ export class DeesHarnessSessionList extends DeesElement {
2527
2428
  : `Reassign ${verb} for ${resourceTitle} from ${fromTitle} to ${toTitle}.`;
2528
2429
  }
2529
2430
 
2530
- private async animateCardReflow(
2431
+ private async animateRowReflow(
2531
2432
  previousRectsArg: Map<string, DOMRect>,
2532
2433
  generationArg: number,
2533
2434
  ): Promise<void> {
@@ -2538,15 +2439,16 @@ export class DeesHarnessSessionList extends DeesElement {
2538
2439
  || globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches
2539
2440
  ) return;
2540
2441
  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);
2442
+ const draggingKey = this.draggingItem ? itemKey(this.draggingItem) : '';
2443
+ for (const row of this.shadowRoot?.querySelectorAll<HTMLElement>('[data-item-kind][data-item-id]') ?? []) {
2444
+ const key = this.rowKey(row);
2445
+ if (key === draggingKey) continue;
2446
+ const previous = previousRectsArg.get(key);
2545
2447
  if (!previous) continue;
2546
- const current = item.getBoundingClientRect();
2448
+ const current = row.getBoundingClientRect();
2547
2449
  const deltaY = previous.top - current.top;
2548
2450
  if (Math.abs(deltaY) < 0.5) continue;
2549
- const animation = item.animate(
2451
+ const animation = row.animate(
2550
2452
  [
2551
2453
  { transform: `translateY(${deltaY}px)` },
2552
2454
  { transform: 'translateY(0)' },
@@ -2568,24 +2470,8 @@ export class DeesHarnessSessionList extends DeesElement {
2568
2470
  private finishPointerDrag(commitArg: boolean): void {
2569
2471
  const gesture = this.pointerDragGesture;
2570
2472
  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
- }
2473
+ const moveDetail = (commitArg && gesture.phase === 'active' && this.dropSlot)
2474
+ ? this.createMoveDetail(gesture.item, this.dropSlot.groupId, this.dropSlot.index, 'pointer')
2589
2475
  : undefined;
2590
2476
  this.pointerDragGesture = undefined;
2591
2477
  window.removeEventListener('keydown', this.handleDragKeyDown, true);
@@ -2595,19 +2481,14 @@ export class DeesHarnessSessionList extends DeesElement {
2595
2481
  this.cancelReflowAnimations();
2596
2482
  if (gesture.source.matches(':popover-open')) gesture.source.hidePopover();
2597
2483
  gesture.source.removeAttribute('popover');
2598
- gesture.source.classList.remove('dragging', 'resourceDragging');
2484
+ gesture.source.classList.remove('dragging');
2599
2485
  for (const property of [
2600
2486
  '--drag-preview-width',
2601
2487
  '--drag-preview-height',
2602
2488
  '--drag-preview-x',
2603
2489
  '--drag-preview-y',
2604
- '--resource-drag-preview-width',
2605
- '--resource-drag-preview-x',
2606
- '--resource-drag-preview-y',
2607
2490
  ]) gesture.source.style.removeProperty(property);
2608
- this.draggingSessionId = '';
2609
- this.draggingResourceId = '';
2610
- this.resourceDropTargetSessionId = '';
2491
+ this.draggingItem = undefined;
2611
2492
  this.draggedItemHeight = 0;
2612
2493
  this.dropSlot = undefined;
2613
2494
  try {
@@ -2617,21 +2498,6 @@ export class DeesHarnessSessionList extends DeesElement {
2617
2498
  } catch {
2618
2499
  // Capture may already be released or the handle may have disconnected.
2619
2500
  }
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 }));
2501
+ if (moveDetail) this.emitItemMove(moveDetail);
2636
2502
  }
2637
2503
  }