@design.estate/dees-catalog 6.6.0 → 6.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,10 +13,17 @@ import {
13
13
  import { themeDefaultStyles } from '../../00theme.js';
14
14
  import type {
15
15
  IHarnessGroupContextDetail,
16
+ IHarnessResourceAssociation,
17
+ IHarnessResourceContextDetail,
18
+ IHarnessResourceEventDetail,
19
+ IHarnessResourceMeta,
16
20
  IHarnessSessionContextDetail,
17
21
  IHarnessSessionGroup,
18
22
  IHarnessSessionMeta,
19
23
  IHarnessSessionMoveDetail,
24
+ THarnessResourceAssociationInteractionSource,
25
+ THarnessResourceAssociationRequestDetail,
26
+ THarnessResourceContextSource,
20
27
  } from '../interfaces.js';
21
28
  import { harnessFormatRelativeTime } from '../harness.helpers.js';
22
29
  import '../../00group-utility/dees-icon/dees-icon.js';
@@ -37,6 +44,7 @@ interface IDropSlot {
37
44
  }
38
45
 
39
46
  interface IPointerDragGesture {
47
+ type: 'session';
40
48
  pointerId: number;
41
49
  handle: HTMLElement;
42
50
  source: HTMLElement;
@@ -50,6 +58,30 @@ interface IPointerDragGesture {
50
58
  phase: 'pending' | 'active';
51
59
  }
52
60
 
61
+ interface IResourcePointerDragGesture {
62
+ type: 'resource';
63
+ pointerId: number;
64
+ handle: HTMLElement;
65
+ source: HTMLElement;
66
+ resourceId: string;
67
+ fromSessionId: string | null;
68
+ startClientX: number;
69
+ startClientY: number;
70
+ grabOffsetX: number;
71
+ grabOffsetY: number;
72
+ phase: 'pending' | 'active';
73
+ }
74
+
75
+ type TPointerDragGesture = IPointerDragGesture | IResourcePointerDragGesture;
76
+
77
+ interface IPendingResourceAssociationRequest {
78
+ resourceId: string;
79
+ resourceTitle: string;
80
+ fromSessionId: string | null;
81
+ toSessionId: string | null;
82
+ intent: THarnessResourceAssociationRequestDetail['intent'];
83
+ }
84
+
53
85
  interface IKeyboardMoveFocus {
54
86
  sessionId: string;
55
87
  groupId: string | null;
@@ -72,7 +104,9 @@ type TSectionRenderEntry =
72
104
  * `harness-group-context` — the host persists changes and passes fresh
73
105
  * `groups` back in. Sessions in no group render in the trailing ungrouped
74
106
  * section with pinned entries first, then explicit order, then new sessions by
75
- * recency. While a search query is active the list is flat.
107
+ * recency. While a search query is active the list is flat. Generic resources
108
+ * project as controlled child rows or into a trailing unattached Resources
109
+ * section; association interactions only emit host requests.
76
110
  */
77
111
  @customElement('dees-harness-session-list')
78
112
  export class DeesHarnessSessionList extends DeesElement {
@@ -101,12 +135,31 @@ export class DeesHarnessSessionList extends DeesElement {
101
135
  @property({ attribute: false })
102
136
  accessor ungroupedSessionIds: string[] = [];
103
137
 
138
+ @property({ attribute: false })
139
+ accessor resources: IHarnessResourceMeta[] = [];
140
+
141
+ @property({ attribute: false })
142
+ accessor resourceAssociations: IHarnessResourceAssociation[] = [];
143
+
144
+ @property()
145
+ accessor selectedResourceId = '';
146
+
147
+ /** Serializable attach/reassign targets. A missing resource entry means no eligible targets. */
148
+ @property({ attribute: false })
149
+ accessor eligibleSessionIdsByResourceId: Record<string, string[]> = {};
150
+
104
151
  @state()
105
152
  accessor search: string = '';
106
153
 
107
154
  @state()
108
155
  accessor draggingSessionId: string = '';
109
156
 
157
+ @state()
158
+ accessor draggingResourceId = '';
159
+
160
+ @state()
161
+ accessor resourceDropTargetSessionId = '';
162
+
110
163
  @state()
111
164
  accessor dropSlot: IDropSlot | undefined = undefined;
112
165
 
@@ -128,9 +181,16 @@ export class DeesHarnessSessionList extends DeesElement {
128
181
  @state()
129
182
  private accessor moveAnnouncement = '';
130
183
 
184
+ @state()
185
+ private accessor resourceContextResourceId = '';
186
+
131
187
  private reflowGeneration = 0;
132
188
  private readonly reflowAnimations = new Set<Animation>();
133
- private pointerDragGesture: IPointerDragGesture | undefined;
189
+ private pointerDragGesture: TPointerDragGesture | undefined;
190
+ private pendingResourceAssociationRequest: IPendingResourceAssociationRequest | undefined;
191
+ private resourceContextFocusTarget: HTMLElement | undefined;
192
+ private resourceContextFocusClass = '';
193
+ private resourceContextGeneration = 0;
134
194
  private keyboardMoveFocus: IKeyboardMoveFocus | undefined;
135
195
  private scrollResizeObserver: ResizeObserver | undefined;
136
196
  private observedScrollContainer: HTMLElement | undefined;
@@ -307,6 +367,8 @@ export class DeesHarnessSessionList extends DeesElement {
307
367
  }
308
368
 
309
369
  .itemInner {
370
+ position: relative;
371
+ isolation: isolate;
310
372
  min-width: 0;
311
373
  overflow: hidden;
312
374
  border: 1px solid var(--card-inner-border);
@@ -339,24 +401,34 @@ export class DeesHarnessSessionList extends DeesElement {
339
401
 
340
402
  @keyframes workingBackgroundScan {
341
403
  from {
342
- background-position: 100% 0;
404
+ transform: translate3d(-60%, 0, 0);
343
405
  }
344
406
  to {
345
- background-position: 0 0;
407
+ transform: translate3d(0, 0, 0);
346
408
  }
347
409
  }
348
410
 
349
411
  .item.state-working .itemInner {
350
- background:
351
- linear-gradient(
352
- 105deg,
353
- transparent 40%,
354
- color-mix(in srgb, var(--session-working) 6%, transparent) 45%,
355
- color-mix(in srgb, var(--session-working) 20%, transparent) 50%,
356
- color-mix(in srgb, var(--session-working) 6%, transparent) 55%,
357
- transparent 60%
358
- ) 100% 0 / 250% 100% no-repeat,
359
- var(--dees-color-bg-primary);
412
+ background: var(--dees-color-bg-primary);
413
+ }
414
+
415
+ .item.state-working .itemInner::before {
416
+ content: '';
417
+ position: absolute;
418
+ z-index: -1;
419
+ inset: 0 auto 0 0;
420
+ width: 250%;
421
+ pointer-events: none;
422
+ background: linear-gradient(
423
+ 105deg,
424
+ transparent 40%,
425
+ color-mix(in srgb, var(--session-working) 6%, transparent) 45%,
426
+ color-mix(in srgb, var(--session-working) 20%, transparent) 50%,
427
+ color-mix(in srgb, var(--session-working) 6%, transparent) 55%,
428
+ transparent 60%
429
+ );
430
+ transform: translate3d(-60%, 0, 0);
431
+ will-change: transform;
360
432
  animation: workingBackgroundScan 2s linear infinite;
361
433
  }
362
434
 
@@ -387,6 +459,12 @@ export class DeesHarnessSessionList extends DeesElement {
387
459
  --card-accent: var(--session-error);
388
460
  }
389
461
 
462
+ .item.resourceDropTarget {
463
+ outline: 2px solid var(--dees-color-accent-primary);
464
+ outline-offset: 2px;
465
+ box-shadow: 0 0 0 5px var(--dees-color-focus-ring), var(--dees-shadow-lg);
466
+ }
467
+
390
468
  @keyframes attentionPulse {
391
469
  0%, 100% {
392
470
  background: color-mix(in srgb, var(--dees-color-bg-primary) 94%, var(--session-attention) 6%);
@@ -561,6 +639,183 @@ export class DeesHarnessSessionList extends DeesElement {
561
639
  font-size: var(--dees-font-caption2-size, 10px);
562
640
  }
563
641
 
642
+ .resourceDropLabel {
643
+ margin: 0 10px 10px;
644
+ padding: 6px 9px;
645
+ border-radius: var(--dees-radius-md);
646
+ background: color-mix(in srgb, var(--dees-color-accent-primary) 14%, transparent);
647
+ color: var(--dees-color-accent-primary);
648
+ font-size: var(--dees-font-caption1-size, 11px);
649
+ font-weight: 680;
650
+ text-align: center;
651
+ }
652
+
653
+ .resourceChildren {
654
+ display: grid;
655
+ gap: 4px;
656
+ margin: 0 8px 8px 24px;
657
+ padding: 0;
658
+ list-style: none;
659
+ }
660
+
661
+ .resourceSection {
662
+ margin-top: var(--dees-spacing-md);
663
+ }
664
+
665
+ .resourceSectionTitle {
666
+ margin: 0 0 7px;
667
+ padding: var(--dees-spacing-xs) 6px;
668
+ color: var(--dees-color-text-secondary);
669
+ font-size: var(--dees-font-caption1-size, 11px);
670
+ font-weight: 650;
671
+ letter-spacing: 0.05em;
672
+ text-transform: uppercase;
673
+ }
674
+
675
+ .resourceList {
676
+ display: grid;
677
+ gap: var(--dees-spacing-sm);
678
+ margin: 0;
679
+ padding: 0;
680
+ list-style: none;
681
+ }
682
+
683
+ .resourceRow {
684
+ display: grid;
685
+ grid-template-columns: 26px minmax(0, 1fr) 38px;
686
+ min-width: 0;
687
+ overflow: hidden;
688
+ border: 1px solid var(--dees-color-border-subtle);
689
+ border-radius: var(--dees-radius-lg);
690
+ background: color-mix(in srgb, var(--dees-color-bg-secondary) 76%, transparent);
691
+ color: var(--dees-color-text-primary);
692
+ box-shadow: var(--dees-shadow-xs);
693
+ box-sizing: border-box;
694
+ }
695
+
696
+ .resourceRow.selected {
697
+ border-color: var(--dees-color-accent-primary);
698
+ background: color-mix(in srgb, var(--dees-color-accent-primary) 10%, var(--dees-color-bg-secondary));
699
+ }
700
+
701
+ .resourceRow.resourceDragging {
702
+ position: fixed;
703
+ inset: 0 auto auto 0;
704
+ width: var(--resource-drag-preview-width);
705
+ max-width: none;
706
+ margin: 0;
707
+ opacity: 0.96;
708
+ pointer-events: none;
709
+ transform: translate3d(var(--resource-drag-preview-x), var(--resource-drag-preview-y), 0);
710
+ box-shadow: var(--dees-shadow-xl);
711
+ }
712
+
713
+ .resourceDragHandle,
714
+ .resourcePrimary,
715
+ .resourceActions {
716
+ appearance: none;
717
+ border: 0;
718
+ background: transparent;
719
+ color: inherit;
720
+ font-family: inherit;
721
+ }
722
+
723
+ .resourceDragHandle {
724
+ display: grid;
725
+ place-items: center;
726
+ min-width: 26px;
727
+ padding: 0;
728
+ color: var(--dees-color-text-muted);
729
+ cursor: grab;
730
+ touch-action: none;
731
+ }
732
+
733
+ .resourceDragHandle:disabled {
734
+ opacity: 0.35;
735
+ cursor: default;
736
+ }
737
+
738
+ .resourceDragHandle:not(:disabled):active {
739
+ cursor: grabbing;
740
+ }
741
+
742
+ .resourcePrimary {
743
+ display: flex;
744
+ min-width: 0;
745
+ min-height: 38px;
746
+ align-items: center;
747
+ gap: 7px;
748
+ padding: 5px 4px 5px 7px;
749
+ text-align: left;
750
+ cursor: pointer;
751
+ }
752
+
753
+ .resourceIcon {
754
+ flex: 0 0 auto;
755
+ color: var(--dees-color-text-secondary);
756
+ }
757
+
758
+ .resourceCopy {
759
+ min-width: 0;
760
+ flex: 1;
761
+ }
762
+
763
+ .resourceTitle {
764
+ display: block;
765
+ overflow: hidden;
766
+ color: var(--dees-color-text-primary);
767
+ font-size: var(--dees-font-caption1-size, 11px);
768
+ font-weight: 620;
769
+ text-overflow: ellipsis;
770
+ white-space: nowrap;
771
+ }
772
+
773
+ .resourcePreview {
774
+ display: block;
775
+ overflow: hidden;
776
+ margin-top: 1px;
777
+ color: var(--dees-color-text-secondary);
778
+ font-size: var(--dees-font-caption2-size, 10px);
779
+ text-overflow: ellipsis;
780
+ white-space: nowrap;
781
+ }
782
+
783
+ .resourceMeta {
784
+ display: block;
785
+ overflow: hidden;
786
+ margin-top: 1px;
787
+ color: var(--dees-color-text-muted);
788
+ font-size: var(--dees-font-caption2-size, 10px);
789
+ text-overflow: ellipsis;
790
+ white-space: nowrap;
791
+ }
792
+
793
+ .resourceActions {
794
+ display: grid;
795
+ place-items: center;
796
+ min-width: 34px;
797
+ min-height: 34px;
798
+ margin: 2px;
799
+ padding: 0;
800
+ border-radius: var(--dees-radius-md);
801
+ color: var(--dees-color-text-muted);
802
+ cursor: pointer;
803
+ }
804
+
805
+ .resourceActions:hover {
806
+ background: var(--dees-color-hover);
807
+ color: var(--dees-color-text-primary);
808
+ }
809
+
810
+ .resourceDragHandle:focus-visible,
811
+ .resourcePrimary:focus-visible,
812
+ .resourceActions:focus-visible {
813
+ z-index: 1;
814
+ outline: 2px solid var(--dees-color-accent-primary);
815
+ outline-offset: -3px;
816
+ border-radius: var(--dees-radius-md);
817
+ }
818
+
564
819
  .visuallyHidden {
565
820
  position: absolute;
566
821
  width: 1px;
@@ -680,12 +935,45 @@ export class DeesHarnessSessionList extends DeesElement {
680
935
  .item,
681
936
  .itemInner,
682
937
  .item.state-attention .itemInner,
938
+ .item.state-working .itemInner::before,
683
939
  .item.state-working .itemHeader .stateDot,
684
940
  .scrollShadow,
685
941
  .expandToggle dees-icon {
686
942
  animation: none;
687
943
  transition: none;
688
944
  }
945
+
946
+ .item.state-working .itemInner::before {
947
+ transform: translate3d(-60%, 0, 0);
948
+ will-change: auto;
949
+ }
950
+ }
951
+
952
+ @media (pointer: coarse) {
953
+ .resourceRow {
954
+ grid-template-columns: 44px minmax(0, 1fr) 44px;
955
+ }
956
+
957
+ .resourceDragHandle,
958
+ .resourceActions {
959
+ min-width: 44px;
960
+ min-height: 44px;
961
+ margin: 0;
962
+ }
963
+
964
+ .resourcePrimary {
965
+ min-height: 44px;
966
+ }
967
+ }
968
+
969
+ @media (max-width: 280px) {
970
+ .resourceChildren {
971
+ margin-left: 8px;
972
+ }
973
+
974
+ .resourcePreview {
975
+ display: none;
976
+ }
689
977
  }
690
978
  `,
691
979
  ];
@@ -694,6 +982,70 @@ export class DeesHarnessSessionList extends DeesElement {
694
982
  return new Map(this.sessions.map((session) => [session.id, session]));
695
983
  }
696
984
 
985
+ private get resourcesById(): Map<string, IHarnessResourceMeta> {
986
+ const result = new Map<string, IHarnessResourceMeta>();
987
+ for (const resource of this.resources) {
988
+ if (!resource.id || !resource.kind || !resource.title || result.has(resource.id)) continue;
989
+ result.set(resource.id, resource);
990
+ }
991
+ return result;
992
+ }
993
+
994
+ /** First valid record wins; unknown and duplicate records remain untouched but are not projected. */
995
+ private get acceptedResourceAssociations(): Map<string, string> {
996
+ const resourcesById = this.resourcesById;
997
+ const sessionsById = this.sessionsById;
998
+ const result = new Map<string, string>();
999
+ for (const association of this.resourceAssociations) {
1000
+ if (
1001
+ !resourcesById.has(association.resourceId)
1002
+ || !sessionsById.has(association.sessionId)
1003
+ || result.has(association.resourceId)
1004
+ ) continue;
1005
+ result.set(association.resourceId, association.sessionId);
1006
+ }
1007
+ return result;
1008
+ }
1009
+
1010
+ private get acceptedResources(): IHarnessResourceMeta[] {
1011
+ return [...this.resourcesById.values()];
1012
+ }
1013
+
1014
+ private resourceMatchesSearch(resourceArg: IHarnessResourceMeta, queryArg: string): boolean {
1015
+ return [resourceArg.id, resourceArg.kind, resourceArg.title, resourceArg.preview]
1016
+ .filter(Boolean)
1017
+ .join(' ')
1018
+ .toLowerCase()
1019
+ .includes(queryArg);
1020
+ }
1021
+
1022
+ private sessionMatchesSearch(sessionArg: IHarnessSessionMeta, queryArg: string): boolean {
1023
+ return [sessionArg.title, sessionArg.preview, sessionArg.id]
1024
+ .filter(Boolean)
1025
+ .join(' ')
1026
+ .toLowerCase()
1027
+ .includes(queryArg);
1028
+ }
1029
+
1030
+ private resourcesForSession(sessionArg: IHarnessSessionMeta): IHarnessResourceMeta[] {
1031
+ const associations = this.acceptedResourceAssociations;
1032
+ const associated = this.acceptedResources.filter(
1033
+ (resource) => associations.get(resource.id) === sessionArg.id,
1034
+ );
1035
+ const query = this.search.trim().toLowerCase();
1036
+ if (!query || this.sessionMatchesSearch(sessionArg, query)) return associated;
1037
+ return associated.filter((resource) => this.resourceMatchesSearch(resource, query));
1038
+ }
1039
+
1040
+ private get unattachedResources(): IHarnessResourceMeta[] {
1041
+ const associations = this.acceptedResourceAssociations;
1042
+ const query = this.search.trim().toLowerCase();
1043
+ return this.acceptedResources.filter((resource) => (
1044
+ !associations.has(resource.id)
1045
+ && (!query || this.resourceMatchesSearch(resource, query))
1046
+ ));
1047
+ }
1048
+
697
1049
  private get groupedSessionIds(): Set<string> {
698
1050
  const ids = new Set<string>();
699
1051
  for (const group of this.groups) {
@@ -726,12 +1078,19 @@ export class DeesHarnessSessionList extends DeesElement {
726
1078
 
727
1079
  private get filteredSessions(): IHarnessSessionMeta[] {
728
1080
  const query = this.search.trim().toLowerCase();
1081
+ const associations = this.acceptedResourceAssociations;
1082
+ const matchingAttachedSessionIds = new Set(
1083
+ this.acceptedResources
1084
+ .filter((resource) => query && this.resourceMatchesSearch(resource, query))
1085
+ .map((resource) => associations.get(resource.id))
1086
+ .filter((sessionId): sessionId is string => sessionId !== undefined),
1087
+ );
729
1088
  const sorted = [...this.sessions].sort(
730
1089
  (left, right) => (right.updatedAt ?? right.createdAt ?? 0) - (left.updatedAt ?? left.createdAt ?? 0),
731
1090
  );
732
1091
  if (!query) return sorted;
733
- return sorted.filter((session) =>
734
- [session.title, session.preview, session.id].filter(Boolean).join(' ').toLowerCase().includes(query),
1092
+ return sorted.filter(
1093
+ (session) => this.sessionMatchesSearch(session, query) || matchingAttachedSessionIds.has(session.id),
735
1094
  );
736
1095
  }
737
1096
 
@@ -739,6 +1098,61 @@ export class DeesHarnessSessionList extends DeesElement {
739
1098
  this.shadowRoot?.querySelector<HTMLInputElement>('.searchRow input')?.focus();
740
1099
  }
741
1100
 
1101
+ public requestResourceAssociation(
1102
+ resourceIdArg: string,
1103
+ toSessionIdArg: string | null,
1104
+ sourceArg: THarnessResourceAssociationInteractionSource = 'action',
1105
+ ): boolean {
1106
+ const resource = this.resourcesById.get(resourceIdArg);
1107
+ if (!resource) return false;
1108
+ const fromSessionId = this.acceptedResourceAssociations.get(resourceIdArg) ?? null;
1109
+ if (fromSessionId === toSessionIdArg) return false;
1110
+ if (
1111
+ toSessionIdArg !== null
1112
+ && (!this.sessionsById.has(toSessionIdArg) || !this.isResourceTargetEligible(resourceIdArg, toSessionIdArg))
1113
+ ) return false;
1114
+ if (toSessionIdArg === null && fromSessionId === null) return false;
1115
+ const detail: THarnessResourceAssociationRequestDetail = fromSessionId === null
1116
+ ? {
1117
+ intent: 'attach',
1118
+ resource,
1119
+ fromSessionId: null,
1120
+ toSessionId: toSessionIdArg!,
1121
+ source: sourceArg,
1122
+ }
1123
+ : toSessionIdArg === null
1124
+ ? {
1125
+ intent: 'detach',
1126
+ resource,
1127
+ fromSessionId,
1128
+ toSessionId: null,
1129
+ source: sourceArg,
1130
+ }
1131
+ : {
1132
+ intent: 'reassign',
1133
+ resource,
1134
+ fromSessionId,
1135
+ toSessionId: toSessionIdArg,
1136
+ source: sourceArg,
1137
+ };
1138
+ this.pendingResourceAssociationRequest = {
1139
+ resourceId: resource.id,
1140
+ resourceTitle: resource.title || resource.id,
1141
+ fromSessionId,
1142
+ toSessionId: toSessionIdArg,
1143
+ intent: detail.intent,
1144
+ };
1145
+ this.moveAnnouncement = this.resourceAssociationAnnouncement(detail, false);
1146
+ this.dispatchEvent(
1147
+ new CustomEvent('harness-resource-association-request', {
1148
+ detail,
1149
+ bubbles: true,
1150
+ composed: true,
1151
+ }),
1152
+ );
1153
+ return true;
1154
+ }
1155
+
742
1156
  public updated(changedProperties: Map<PropertyKey, unknown>): void {
743
1157
  super.updated(changedProperties);
744
1158
  this.observeScrollGeometry();
@@ -750,19 +1164,41 @@ export class DeesHarnessSessionList extends DeesElement {
750
1164
  ) {
751
1165
  this.finishPointerDrag(false);
752
1166
  }
753
- if (!changedProperties.has('sessions')) return;
754
- const knownIds = new Set(this.sessions.map((session) => session.id));
755
- if (this.pointerDragGesture && !knownIds.has(this.pointerDragGesture.sessionId)) {
756
- this.finishPointerDrag(false);
1167
+ if (changedProperties.has('sessions')) {
1168
+ const knownIds = new Set(this.sessions.map((session) => session.id));
1169
+ if (
1170
+ this.pointerDragGesture?.type === 'session'
1171
+ && !knownIds.has(this.pointerDragGesture.sessionId)
1172
+ ) {
1173
+ this.finishPointerDrag(false);
1174
+ }
1175
+ const nextExpanded = new Set(
1176
+ [...this.expandedSessionIds].filter((sessionId) => knownIds.has(sessionId)),
1177
+ );
1178
+ if (
1179
+ nextExpanded.size !== this.expandedSessionIds.size
1180
+ || [...nextExpanded].some((sessionId) => !this.expandedSessionIds.has(sessionId))
1181
+ ) {
1182
+ this.expandedSessionIds = nextExpanded;
1183
+ }
757
1184
  }
758
- const nextExpanded = new Set(
759
- [...this.expandedSessionIds].filter((sessionId) => knownIds.has(sessionId)),
760
- );
761
- if (
762
- nextExpanded.size !== this.expandedSessionIds.size
763
- || [...nextExpanded].some((sessionId) => !this.expandedSessionIds.has(sessionId))
764
- ) {
765
- this.expandedSessionIds = nextExpanded;
1185
+ if (changedProperties.has('resources') || changedProperties.has('resourceAssociations')) {
1186
+ const gesture = this.pointerDragGesture;
1187
+ if (gesture?.type === 'resource') {
1188
+ const currentSessionId = this.acceptedResourceAssociations.get(gesture.resourceId) ?? null;
1189
+ if (!this.resourcesById.has(gesture.resourceId) || currentSessionId !== gesture.fromSessionId) {
1190
+ this.finishPointerDrag(false);
1191
+ }
1192
+ }
1193
+ if (
1194
+ this.resourceContextResourceId
1195
+ && !this.resourcesById.has(this.resourceContextResourceId)
1196
+ ) this.closeResourceContext(this.resourceContextGeneration);
1197
+ this.reconcilePendingResourceAssociation();
1198
+ }
1199
+ if (changedProperties.has('eligibleSessionIdsByResourceId')) {
1200
+ const gesture = this.pointerDragGesture;
1201
+ if (gesture?.type === 'resource') this.finishPointerDrag(false);
766
1202
  }
767
1203
  }
768
1204
 
@@ -776,6 +1212,7 @@ export class DeesHarnessSessionList extends DeesElement {
776
1212
 
777
1213
  public async disconnectedCallback(): Promise<void> {
778
1214
  this.finishPointerDrag(false);
1215
+ this.clearResourceContextState();
779
1216
  this.scrollResizeObserver?.disconnect();
780
1217
  this.scrollResizeObserver = undefined;
781
1218
  this.observedScrollContainer = undefined;
@@ -789,7 +1226,12 @@ export class DeesHarnessSessionList extends DeesElement {
789
1226
  <div class="searchRow">
790
1227
  <dees-icon class="searchIcon" icon="lucide:Search" iconSize="13"></dees-icon>
791
1228
  <input
792
- placeholder="Search conversations"
1229
+ placeholder=${this.acceptedResources.length
1230
+ ? 'Search conversations and resources'
1231
+ : 'Search conversations'}
1232
+ aria-label=${this.acceptedResources.length
1233
+ ? 'Search conversations and resources'
1234
+ : 'Search conversations'}
793
1235
  .value=${this.search}
794
1236
  @input=${(event: Event) => {
795
1237
  this.search = (event.currentTarget as HTMLInputElement).value;
@@ -839,9 +1281,16 @@ export class DeesHarnessSessionList extends DeesElement {
839
1281
 
840
1282
  private renderFlatList(): TemplateResult {
841
1283
  const sessions = this.filteredSessions;
842
- if (!sessions.length) {
1284
+ const unattachedResources = this.unattachedResources;
1285
+ if (!sessions.length && !unattachedResources.length) {
843
1286
  return html`<div class="emptyNote">
844
- ${this.sessions.length ? 'No conversations match this search.' : this.emptyText}
1287
+ ${this.acceptedResources.length
1288
+ ? this.sessions.length
1289
+ ? 'No conversations or resources match this search.'
1290
+ : 'No resources match this search.'
1291
+ : this.sessions.length
1292
+ ? 'No conversations match this search.'
1293
+ : this.emptyText}
845
1294
  </div>`;
846
1295
  }
847
1296
  return html`
@@ -852,13 +1301,15 @@ export class DeesHarnessSessionList extends DeesElement {
852
1301
  (session) => this.renderItem(session, null, -1),
853
1302
  )}
854
1303
  </div>
1304
+ ${this.renderUnattachedResources(unattachedResources)}
855
1305
  `;
856
1306
  }
857
1307
 
858
1308
  private renderGroupedList(): TemplateResult {
859
1309
  const byId = this.sessionsById;
860
1310
  const ungrouped = this.ungroupedSessions;
861
- if (!this.sessions.length && !this.groups.length) {
1311
+ const unattachedResources = this.unattachedResources;
1312
+ if (!this.sessions.length && !this.groups.length && !unattachedResources.length) {
862
1313
  return html`<div class="emptyNote">${this.emptyText}</div>`;
863
1314
  }
864
1315
  return html`
@@ -917,6 +1368,7 @@ export class DeesHarnessSessionList extends DeesElement {
917
1368
  `
918
1369
  : ''}
919
1370
  ${this.renderSection(ungrouped, null)}
1371
+ ${this.renderUnattachedResources(unattachedResources)}
920
1372
  `;
921
1373
  }
922
1374
 
@@ -1000,12 +1452,16 @@ export class DeesHarnessSessionList extends DeesElement {
1000
1452
  const state = session.state ?? 'normal';
1001
1453
  const detailsId = `session-details-${session.id}`;
1002
1454
  const dropIndex = index < 0 ? 0 : index;
1455
+ const resources = this.resourcesForSession(session);
1456
+ const isResourceDropTarget = this.resourceDropTargetSessionId === session.id;
1003
1457
  return html`
1004
1458
  <article
1005
1459
  class="item state-${state} ${expanded ? 'expanded' : ''} ${
1006
1460
  session.id === this.selectedSessionId ? 'selected' : ''
1007
1461
  } ${
1008
1462
  this.draggingSessionId === session.id ? 'dragging' : ''
1463
+ } ${
1464
+ isResourceDropTarget ? 'resourceDropTarget' : ''
1009
1465
  }"
1010
1466
  data-session-id=${session.id}
1011
1467
  data-drop-group-id=${groupId ?? ''}
@@ -1091,6 +1547,20 @@ export class DeesHarnessSessionList extends DeesElement {
1091
1547
  <dees-icon icon="lucide:ChevronDown" iconSize="15"></dees-icon>
1092
1548
  </button>
1093
1549
  </div>
1550
+ ${isResourceDropTarget
1551
+ ? html`<div class="resourceDropLabel">Associate resource with ${session.title || session.id}</div>`
1552
+ : ''}
1553
+ ${resources.length
1554
+ ? html`
1555
+ <ul class="resourceChildren" aria-label=${`Resources for ${session.title || session.id}`}>
1556
+ ${repeat(
1557
+ resources,
1558
+ (resource) => resource.id,
1559
+ (resource) => this.renderResource(resource, session.id),
1560
+ )}
1561
+ </ul>
1562
+ `
1563
+ : ''}
1094
1564
  ${expanded
1095
1565
  ? html`
1096
1566
  <div class="itemDetails" id=${detailsId}>
@@ -1110,6 +1580,129 @@ export class DeesHarnessSessionList extends DeesElement {
1110
1580
  `;
1111
1581
  }
1112
1582
 
1583
+ private renderUnattachedResources(resourcesArg: IHarnessResourceMeta[]): TemplateResult {
1584
+ if (!resourcesArg.length) return html``;
1585
+ return html`
1586
+ <section class="resourceSection" aria-labelledby="unattached-resources-heading">
1587
+ <h2 class="resourceSectionTitle" id="unattached-resources-heading">Resources</h2>
1588
+ <ul class="resourceList" aria-label="Unattached resources">
1589
+ ${repeat(
1590
+ resourcesArg,
1591
+ (resource) => resource.id,
1592
+ (resource) => this.renderResource(resource, null),
1593
+ )}
1594
+ </ul>
1595
+ </section>
1596
+ `;
1597
+ }
1598
+
1599
+ private renderResource(
1600
+ resourceArg: IHarnessResourceMeta,
1601
+ sessionIdArg: string | null,
1602
+ ): TemplateResult {
1603
+ const selected = resourceArg.id === this.selectedResourceId;
1604
+ const dragging = resourceArg.id === this.draggingResourceId;
1605
+ const eligibleTargetCount = this.eligibleSessionIds(resourceArg.id).filter(
1606
+ (sessionId) => sessionId !== sessionIdArg,
1607
+ ).length;
1608
+ const contextOpen = resourceArg.id === this.resourceContextResourceId;
1609
+ return html`
1610
+ <li
1611
+ class="resourceRow ${selected ? 'selected' : ''} ${dragging ? 'resourceDragging' : ''}"
1612
+ data-resource-id=${resourceArg.id}
1613
+ data-resource-session-id=${sessionIdArg ?? ''}
1614
+ @dblclick=${(event: MouseEvent) => {
1615
+ event.stopPropagation();
1616
+ if (this.eventTargetsResourceControl(event)) return;
1617
+ this.emitResource('harness-resource-open', resourceArg, sessionIdArg);
1618
+ }}
1619
+ @contextmenu=${(event: MouseEvent) => {
1620
+ event.preventDefault();
1621
+ event.stopPropagation();
1622
+ const focusTarget = (event.composedPath().find((target) => (
1623
+ target instanceof HTMLElement
1624
+ && target.matches('button:not(:disabled)')
1625
+ )) as HTMLElement | undefined)
1626
+ ?? (event.currentTarget as HTMLElement).querySelector<HTMLElement>('.resourcePrimary')
1627
+ ?? event.currentTarget as HTMLElement;
1628
+ this.emitResourceContext(event, resourceArg, sessionIdArg, 'pointer', focusTarget);
1629
+ }}
1630
+ >
1631
+ <button
1632
+ class="resourceDragHandle"
1633
+ type="button"
1634
+ title=${eligibleTargetCount
1635
+ ? 'Drag to associate with an eligible conversation'
1636
+ : 'No eligible conversations'}
1637
+ aria-label=${`Associate ${resourceArg.title || resourceArg.id} by dragging`}
1638
+ aria-keyshortcuts="ContextMenu Shift+F10"
1639
+ ?disabled=${eligibleTargetCount === 0}
1640
+ @pointerdown=${(event: PointerEvent) => {
1641
+ this.handleResourcePointerDown(event, resourceArg, sessionIdArg);
1642
+ }}
1643
+ @pointermove=${this.handlePointerMove}
1644
+ @pointerup=${this.handlePointerUp}
1645
+ @pointercancel=${this.handlePointerCancel}
1646
+ @lostpointercapture=${this.handleLostPointerCapture}
1647
+ @keydown=${(event: KeyboardEvent) => {
1648
+ this.handleResourceContextKey(event, resourceArg, sessionIdArg);
1649
+ }}
1650
+ >
1651
+ <dees-icon icon="lucide:GripVertical" iconSize="13"></dees-icon>
1652
+ </button>
1653
+ <button
1654
+ class="resourcePrimary"
1655
+ type="button"
1656
+ aria-current=${selected ? 'true' : 'false'}
1657
+ @click=${() => this.emitResource('harness-resource-select', resourceArg, sessionIdArg)}
1658
+ @keydown=${(event: KeyboardEvent) => {
1659
+ if (event.key === 'Enter') {
1660
+ event.preventDefault();
1661
+ this.emitResource('harness-resource-open', resourceArg, sessionIdArg);
1662
+ return;
1663
+ }
1664
+ this.handleResourceContextKey(event, resourceArg, sessionIdArg);
1665
+ }}
1666
+ >
1667
+ <dees-icon
1668
+ class="resourceIcon"
1669
+ icon=${resourceArg.icon || 'lucide:Box'}
1670
+ iconSize="14"
1671
+ ></dees-icon>
1672
+ <span class="resourceCopy">
1673
+ <span class="resourceTitle">${resourceArg.title || resourceArg.id}</span>
1674
+ ${resourceArg.preview ? html`<span class="resourcePreview">${resourceArg.preview}</span>` : ''}
1675
+ <span class="resourceMeta">
1676
+ ${resourceArg.kind}${resourceArg.state ? ` · ${resourceArg.state}` : ''}${
1677
+ resourceArg.updatedAt || resourceArg.createdAt
1678
+ ? ` · ${harnessFormatRelativeTime(resourceArg.updatedAt ?? resourceArg.createdAt)}`
1679
+ : ''
1680
+ }
1681
+ </span>
1682
+ </span>
1683
+ </button>
1684
+ <button
1685
+ class="resourceActions"
1686
+ type="button"
1687
+ title="Resource actions"
1688
+ aria-label=${`Actions for ${resourceArg.title || resourceArg.id}`}
1689
+ aria-haspopup="menu"
1690
+ aria-expanded=${contextOpen ? 'true' : 'false'}
1691
+ aria-keyshortcuts="ContextMenu Shift+F10"
1692
+ @click=${(event: MouseEvent) => {
1693
+ event.stopPropagation();
1694
+ this.emitResourceContext(event, resourceArg, sessionIdArg, 'action', event.currentTarget as HTMLElement);
1695
+ }}
1696
+ @keydown=${(event: KeyboardEvent) => {
1697
+ this.handleResourceContextKey(event, resourceArg, sessionIdArg);
1698
+ }}
1699
+ >
1700
+ <dees-icon icon="lucide:Ellipsis" iconSize="15"></dees-icon>
1701
+ </button>
1702
+ </li>
1703
+ `;
1704
+ }
1705
+
1113
1706
  private eventTargetsExpandToggle(event: Event): boolean {
1114
1707
  return event.composedPath().some(
1115
1708
  (target) => target instanceof HTMLElement && target.classList.contains('expandToggle'),
@@ -1122,6 +1715,15 @@ export class DeesHarnessSessionList extends DeesElement {
1122
1715
  );
1123
1716
  }
1124
1717
 
1718
+ private eventTargetsResourceControl(event: Event): boolean {
1719
+ return event.composedPath().some(
1720
+ (target) => target instanceof HTMLElement && (
1721
+ target.classList.contains('resourceDragHandle')
1722
+ || target.classList.contains('resourceActions')
1723
+ ),
1724
+ );
1725
+ }
1726
+
1125
1727
  private sessionStateLabel(state: NonNullable<IHarnessSessionMeta['state']>): string {
1126
1728
  switch (state) {
1127
1729
  case 'working': return 'Working';
@@ -1186,6 +1788,7 @@ export class DeesHarnessSessionList extends DeesElement {
1186
1788
  if (!source) return;
1187
1789
  const sourceRect = source.getBoundingClientRect();
1188
1790
  this.pointerDragGesture = {
1791
+ type: 'session',
1189
1792
  pointerId: eventArg.pointerId,
1190
1793
  handle,
1191
1794
  source,
@@ -1207,6 +1810,43 @@ export class DeesHarnessSessionList extends DeesElement {
1207
1810
  }
1208
1811
  }
1209
1812
 
1813
+ private handleResourcePointerDown(
1814
+ eventArg: PointerEvent,
1815
+ resourceArg: IHarnessResourceMeta,
1816
+ fromSessionIdArg: string | null,
1817
+ ): void {
1818
+ if (
1819
+ this.pointerDragGesture
1820
+ || eventArg.button !== 0
1821
+ || !eventArg.isPrimary
1822
+ || this.eligibleSessionIds(resourceArg.id).every((sessionId) => sessionId === fromSessionIdArg)
1823
+ ) return;
1824
+ const handle = eventArg.currentTarget as HTMLElement;
1825
+ const source = handle.closest<HTMLElement>('.resourceRow');
1826
+ if (!source) return;
1827
+ const sourceRect = source.getBoundingClientRect();
1828
+ this.pointerDragGesture = {
1829
+ type: 'resource',
1830
+ pointerId: eventArg.pointerId,
1831
+ handle,
1832
+ source,
1833
+ resourceId: resourceArg.id,
1834
+ fromSessionId: fromSessionIdArg,
1835
+ startClientX: eventArg.clientX,
1836
+ startClientY: eventArg.clientY,
1837
+ grabOffsetX: eventArg.clientX - sourceRect.left,
1838
+ grabOffsetY: eventArg.clientY - sourceRect.top,
1839
+ phase: 'pending',
1840
+ };
1841
+ window.addEventListener('keydown', this.handleDragKeyDown, true);
1842
+ window.addEventListener('blur', this.handleDragWindowBlur);
1843
+ try {
1844
+ handle.setPointerCapture(eventArg.pointerId);
1845
+ } catch {
1846
+ this.finishPointerDrag(false);
1847
+ }
1848
+ }
1849
+
1210
1850
  private readonly handlePointerMove = (eventArg: PointerEvent): void => {
1211
1851
  const gesture = this.pointerDragGesture;
1212
1852
  if (!gesture || eventArg.pointerId !== gesture.pointerId) return;
@@ -1221,8 +1861,21 @@ export class DeesHarnessSessionList extends DeesElement {
1221
1861
  eventArg.clientY - gesture.startClientY,
1222
1862
  ) < dragThresholdPixels
1223
1863
  ) return;
1224
- if (gesture.phase === 'pending' && !this.startPointerDrag(eventArg, gesture)) return;
1864
+ if (
1865
+ gesture.phase === 'pending'
1866
+ && !(gesture.type === 'session'
1867
+ ? this.startPointerDrag(eventArg, gesture)
1868
+ : this.startResourcePointerDrag(eventArg, gesture))
1869
+ ) return;
1225
1870
  eventArg.preventDefault();
1871
+ if (gesture.type === 'resource') {
1872
+ this.moveResourceDragPreview(eventArg.clientX, eventArg.clientY, gesture);
1873
+ this.dragAutoScrollClientX = eventArg.clientX;
1874
+ this.dragAutoScrollClientY = eventArg.clientY;
1875
+ this.updateDragAutoScrollVelocity();
1876
+ this.updateResourceDropTargetFromPointer(eventArg.clientX, eventArg.clientY);
1877
+ return;
1878
+ }
1226
1879
  this.moveDragPreview(eventArg.clientX, eventArg.clientY, gesture);
1227
1880
  this.dragAutoScrollClientX = eventArg.clientX;
1228
1881
  this.dragAutoScrollClientY = eventArg.clientY;
@@ -1234,6 +1887,14 @@ export class DeesHarnessSessionList extends DeesElement {
1234
1887
  const gesture = this.pointerDragGesture;
1235
1888
  if (!gesture || eventArg.pointerId !== gesture.pointerId) return;
1236
1889
  if (gesture.phase === 'active') {
1890
+ if (gesture.type === 'resource') {
1891
+ this.dragAutoScrollClientX = eventArg.clientX;
1892
+ this.dragAutoScrollClientY = eventArg.clientY;
1893
+ this.moveResourceDragPreview(eventArg.clientX, eventArg.clientY, gesture);
1894
+ this.updateResourceDropTargetFromPointer(eventArg.clientX, eventArg.clientY);
1895
+ this.finishPointerDrag(true);
1896
+ return;
1897
+ }
1237
1898
  this.dragAutoScrollClientX = eventArg.clientX;
1238
1899
  this.dragAutoScrollClientY = eventArg.clientY;
1239
1900
  this.moveDragPreview(eventArg.clientX, eventArg.clientY, gesture);
@@ -1289,6 +1950,27 @@ export class DeesHarnessSessionList extends DeesElement {
1289
1950
  return true;
1290
1951
  }
1291
1952
 
1953
+ private startResourcePointerDrag(
1954
+ eventArg: PointerEvent,
1955
+ gestureArg: IResourcePointerDragGesture,
1956
+ ): boolean {
1957
+ const sourceRect = gestureArg.source.getBoundingClientRect();
1958
+ gestureArg.source.classList.add('resourceDragging');
1959
+ gestureArg.source.style.setProperty('--resource-drag-preview-width', `${sourceRect.width}px`);
1960
+ this.moveResourceDragPreview(eventArg.clientX, eventArg.clientY, gestureArg);
1961
+ gestureArg.source.setAttribute('popover', 'manual');
1962
+ try {
1963
+ gestureArg.source.showPopover();
1964
+ } catch {
1965
+ this.finishPointerDrag(false);
1966
+ return false;
1967
+ }
1968
+ gestureArg.phase = 'active';
1969
+ this.draggingResourceId = gestureArg.resourceId;
1970
+ this.resourceDropTargetSessionId = '';
1971
+ return true;
1972
+ }
1973
+
1292
1974
  private moveDragPreview(
1293
1975
  clientXArg: number,
1294
1976
  clientYArg: number,
@@ -1304,6 +1986,34 @@ export class DeesHarnessSessionList extends DeesElement {
1304
1986
  );
1305
1987
  }
1306
1988
 
1989
+ private moveResourceDragPreview(
1990
+ clientXArg: number,
1991
+ clientYArg: number,
1992
+ gestureArg: IResourcePointerDragGesture,
1993
+ ): void {
1994
+ gestureArg.source.style.setProperty(
1995
+ '--resource-drag-preview-x',
1996
+ `${clientXArg - gestureArg.grabOffsetX}px`,
1997
+ );
1998
+ gestureArg.source.style.setProperty(
1999
+ '--resource-drag-preview-y',
2000
+ `${clientYArg - gestureArg.grabOffsetY}px`,
2001
+ );
2002
+ }
2003
+
2004
+ private updateResourceDropTargetFromPointer(clientXArg: number, clientYArg: number): void {
2005
+ const gesture = this.pointerDragGesture;
2006
+ if (gesture?.type !== 'resource' || gesture.phase !== 'active') return;
2007
+ const hit = this.shadowRoot?.elementFromPoint(clientXArg, clientYArg);
2008
+ const target = hit?.closest<HTMLElement>('.item[data-session-id]');
2009
+ const sessionId = target?.dataset.sessionId ?? '';
2010
+ this.resourceDropTargetSessionId = (
2011
+ sessionId
2012
+ && sessionId !== gesture.fromSessionId
2013
+ && this.isResourceTargetEligible(gesture.resourceId, sessionId)
2014
+ ) ? sessionId : '';
2015
+ }
2016
+
1307
2017
  private setDropSlot(groupIdArg: string | null, indexArg: number): void {
1308
2018
  const current = this.dropSlot;
1309
2019
  if (current && current.groupId === groupIdArg && current.index === indexArg) return;
@@ -1323,7 +2033,8 @@ export class DeesHarnessSessionList extends DeesElement {
1323
2033
 
1324
2034
  private updateDragAutoScrollVelocity(): void {
1325
2035
  const container = this.scrollContainer;
1326
- if (!container || !this.draggingSessionId) return;
2036
+ const gesture = this.pointerDragGesture;
2037
+ if (!container || gesture?.phase !== 'active') return;
1327
2038
  const rect = container.getBoundingClientRect();
1328
2039
  if (this.dragAutoScrollClientX < rect.left || this.dragAutoScrollClientX > rect.right) {
1329
2040
  this.stopDragAutoScroll();
@@ -1356,7 +2067,8 @@ export class DeesHarnessSessionList extends DeesElement {
1356
2067
  private readonly runDragAutoScroll = (timestampArg: number): void => {
1357
2068
  this.dragAutoScrollFrame = undefined;
1358
2069
  const container = this.scrollContainer;
1359
- if (!container || !this.draggingSessionId || this.dragAutoScrollVelocity === 0) return;
2070
+ const gesture = this.pointerDragGesture;
2071
+ if (!container || gesture?.phase !== 'active' || this.dragAutoScrollVelocity === 0) return;
1360
2072
  const elapsedMs = Math.min(34, Math.max(0, timestampArg - this.dragAutoScrollLastFrame));
1361
2073
  this.dragAutoScrollLastFrame = timestampArg;
1362
2074
  const previousScrollTop = container.scrollTop;
@@ -1369,7 +2081,14 @@ export class DeesHarnessSessionList extends DeesElement {
1369
2081
  this.stopDragAutoScroll();
1370
2082
  return;
1371
2083
  }
1372
- this.updateDropSlotFromPointer();
2084
+ if (gesture.type === 'resource') {
2085
+ this.updateResourceDropTargetFromPointer(
2086
+ this.dragAutoScrollClientX,
2087
+ this.dragAutoScrollClientY,
2088
+ );
2089
+ } else {
2090
+ this.updateDropSlotFromPointer();
2091
+ }
1373
2092
  this.dragAutoScrollFrame = requestAnimationFrame(this.runDragAutoScroll);
1374
2093
  };
1375
2094
 
@@ -1585,6 +2304,171 @@ export class DeesHarnessSessionList extends DeesElement {
1585
2304
  }
1586
2305
  }
1587
2306
 
2307
+ private eligibleSessionIds(resourceIdArg: string): string[] {
2308
+ const knownSessionIds = new Set(this.sessions.map((session) => session.id));
2309
+ const result: string[] = [];
2310
+ const seen = new Set<string>();
2311
+ for (const sessionId of this.eligibleSessionIdsByResourceId[resourceIdArg] ?? []) {
2312
+ if (!knownSessionIds.has(sessionId) || seen.has(sessionId)) continue;
2313
+ seen.add(sessionId);
2314
+ result.push(sessionId);
2315
+ }
2316
+ return result;
2317
+ }
2318
+
2319
+ private isResourceTargetEligible(resourceIdArg: string, sessionIdArg: string): boolean {
2320
+ return this.eligibleSessionIds(resourceIdArg).includes(sessionIdArg);
2321
+ }
2322
+
2323
+ private emitResource(
2324
+ nameArg: string,
2325
+ resourceArg: IHarnessResourceMeta,
2326
+ sessionIdArg: string | null,
2327
+ ): void {
2328
+ const detail: IHarnessResourceEventDetail = {
2329
+ resource: resourceArg,
2330
+ sessionId: sessionIdArg,
2331
+ };
2332
+ this.dispatchEvent(new CustomEvent(nameArg, { detail, bubbles: true, composed: true }));
2333
+ }
2334
+
2335
+ private handleResourceContextKey(
2336
+ eventArg: KeyboardEvent,
2337
+ resourceArg: IHarnessResourceMeta,
2338
+ sessionIdArg: string | null,
2339
+ ): void {
2340
+ if (!(eventArg.key === 'ContextMenu' || (eventArg.key === 'F10' && eventArg.shiftKey))) return;
2341
+ eventArg.preventDefault();
2342
+ eventArg.stopPropagation();
2343
+ this.emitResourceContext(
2344
+ eventArg,
2345
+ resourceArg,
2346
+ sessionIdArg,
2347
+ 'keyboard',
2348
+ eventArg.currentTarget as HTMLElement,
2349
+ );
2350
+ }
2351
+
2352
+ private emitResourceContext(
2353
+ eventArg: MouseEvent | KeyboardEvent,
2354
+ resourceArg: IHarnessResourceMeta,
2355
+ sessionIdArg: string | null,
2356
+ sourceArg: THarnessResourceContextSource,
2357
+ focusTargetArg: HTMLElement,
2358
+ ): void {
2359
+ const rect = focusTargetArg.getBoundingClientRect();
2360
+ const contextGeneration = ++this.resourceContextGeneration;
2361
+ this.resourceContextResourceId = resourceArg.id;
2362
+ this.resourceContextFocusTarget = focusTargetArg;
2363
+ this.resourceContextFocusClass = [
2364
+ 'resourceActions',
2365
+ 'resourcePrimary',
2366
+ 'resourceDragHandle',
2367
+ ].find((className) => focusTargetArg.classList.contains(className)) ?? 'resourcePrimary';
2368
+ const detail: IHarnessResourceContextDetail = {
2369
+ resource: resourceArg,
2370
+ sessionId: sessionIdArg,
2371
+ clientX: sourceArg === 'pointer' && eventArg instanceof MouseEvent
2372
+ ? eventArg.clientX
2373
+ : rect.left + rect.width / 2,
2374
+ clientY: sourceArg === 'pointer' && eventArg instanceof MouseEvent
2375
+ ? eventArg.clientY
2376
+ : rect.bottom,
2377
+ source: sourceArg,
2378
+ originalEvent: eventArg,
2379
+ requestAssociation: (toSessionIdArg) => this.requestResourceAssociation(
2380
+ resourceArg.id,
2381
+ toSessionIdArg,
2382
+ sourceArg === 'keyboard' ? 'keyboard' : 'action',
2383
+ ),
2384
+ close: () => this.closeResourceContext(contextGeneration),
2385
+ };
2386
+ this.dispatchEvent(
2387
+ new CustomEvent('harness-resource-context', { detail, bubbles: true, composed: true }),
2388
+ );
2389
+ }
2390
+
2391
+ private closeResourceContext(contextGenerationArg: number): void {
2392
+ if (
2393
+ !this.isConnected
2394
+ || contextGenerationArg !== this.resourceContextGeneration
2395
+ ) return;
2396
+ const focusTarget = this.resourceContextFocusTarget;
2397
+ const resourceId = this.resourceContextResourceId;
2398
+ const focusClass = this.resourceContextFocusClass;
2399
+ this.clearResourceContextState();
2400
+ const closeGeneration = this.resourceContextGeneration;
2401
+ void this.updateComplete.then(() => {
2402
+ if (!this.isConnected || closeGeneration !== this.resourceContextGeneration) return;
2403
+ if (focusTarget?.isConnected) {
2404
+ focusTarget.focus();
2405
+ return;
2406
+ }
2407
+ const row = Array.from(
2408
+ this.shadowRoot?.querySelectorAll<HTMLElement>('.resourceRow[data-resource-id]') ?? [],
2409
+ ).find((candidate) => candidate.dataset.resourceId === resourceId);
2410
+ row?.querySelector<HTMLElement>(`.${focusClass || 'resourcePrimary'}`)?.focus();
2411
+ });
2412
+ }
2413
+
2414
+ private clearResourceContextState(): void {
2415
+ this.resourceContextGeneration += 1;
2416
+ this.resourceContextResourceId = '';
2417
+ this.resourceContextFocusTarget = undefined;
2418
+ this.resourceContextFocusClass = '';
2419
+ }
2420
+
2421
+ private reconcilePendingResourceAssociation(): void {
2422
+ const pending = this.pendingResourceAssociationRequest;
2423
+ if (!pending) return;
2424
+ if (!this.resourcesById.has(pending.resourceId)) {
2425
+ this.pendingResourceAssociationRequest = undefined;
2426
+ return;
2427
+ }
2428
+ const currentSessionId = this.acceptedResourceAssociations.get(pending.resourceId) ?? null;
2429
+ if (currentSessionId !== pending.toSessionId) {
2430
+ if (currentSessionId !== pending.fromSessionId) {
2431
+ const controlledSession = currentSessionId
2432
+ ? this.sessionsById.get(currentSessionId)?.title ?? currentSessionId
2433
+ : 'unattached';
2434
+ this.moveAnnouncement = `Association request for ${pending.resourceTitle} was not applied; controlled association is ${controlledSession}.`;
2435
+ this.pendingResourceAssociationRequest = undefined;
2436
+ }
2437
+ return;
2438
+ }
2439
+ const fromSession = pending.fromSessionId
2440
+ ? this.sessionsById.get(pending.fromSessionId)?.title ?? pending.fromSessionId
2441
+ : '';
2442
+ const toSession = pending.toSessionId
2443
+ ? this.sessionsById.get(pending.toSessionId)?.title ?? pending.toSessionId
2444
+ : '';
2445
+ this.moveAnnouncement = pending.intent === 'attach'
2446
+ ? `${pending.resourceTitle} is now attached to ${toSession}.`
2447
+ : pending.intent === 'detach'
2448
+ ? `${pending.resourceTitle} is now detached from ${fromSession}.`
2449
+ : `${pending.resourceTitle} is now reassigned from ${fromSession} to ${toSession}.`;
2450
+ this.pendingResourceAssociationRequest = undefined;
2451
+ }
2452
+
2453
+ private resourceAssociationAnnouncement(
2454
+ detailArg: THarnessResourceAssociationRequestDetail,
2455
+ completedArg: boolean,
2456
+ ): string {
2457
+ const resourceTitle = detailArg.resource.title || detailArg.resource.id;
2458
+ const fromTitle = detailArg.fromSessionId
2459
+ ? this.sessionsById.get(detailArg.fromSessionId)?.title ?? detailArg.fromSessionId
2460
+ : '';
2461
+ const toTitle = detailArg.toSessionId
2462
+ ? this.sessionsById.get(detailArg.toSessionId)?.title ?? detailArg.toSessionId
2463
+ : '';
2464
+ const verb = completedArg ? 'completed' : 'requested';
2465
+ return detailArg.intent === 'attach'
2466
+ ? `Attach ${verb} for ${resourceTitle} to ${toTitle}.`
2467
+ : detailArg.intent === 'detach'
2468
+ ? `Detach ${verb} for ${resourceTitle} from ${fromTitle}.`
2469
+ : `Reassign ${verb} for ${resourceTitle} from ${fromTitle} to ${toTitle}.`;
2470
+ }
2471
+
1588
2472
  private async animateCardReflow(
1589
2473
  previousRectsArg: Map<string, DOMRect>,
1590
2474
  generationArg: number,
@@ -1626,9 +2510,25 @@ export class DeesHarnessSessionList extends DeesElement {
1626
2510
  private finishPointerDrag(commitArg: boolean): void {
1627
2511
  const gesture = this.pointerDragGesture;
1628
2512
  if (!gesture) return;
1629
- const detail = commitArg && gesture.phase === 'active' && this.dropSlot
2513
+ const sessionMoveDetail = (
2514
+ gesture.type === 'session'
2515
+ && commitArg
2516
+ && gesture.phase === 'active'
2517
+ && this.dropSlot
2518
+ )
1630
2519
  ? this.createMoveDetail(gesture.sessionId, this.dropSlot.groupId, this.dropSlot.index)
1631
2520
  : undefined;
2521
+ const resourceRequest = (
2522
+ gesture.type === 'resource'
2523
+ && commitArg
2524
+ && gesture.phase === 'active'
2525
+ && this.resourceDropTargetSessionId
2526
+ )
2527
+ ? {
2528
+ resourceId: gesture.resourceId,
2529
+ toSessionId: this.resourceDropTargetSessionId,
2530
+ }
2531
+ : undefined;
1632
2532
  this.pointerDragGesture = undefined;
1633
2533
  window.removeEventListener('keydown', this.handleDragKeyDown, true);
1634
2534
  window.removeEventListener('blur', this.handleDragWindowBlur);
@@ -1637,14 +2537,19 @@ export class DeesHarnessSessionList extends DeesElement {
1637
2537
  this.cancelReflowAnimations();
1638
2538
  if (gesture.source.matches(':popover-open')) gesture.source.hidePopover();
1639
2539
  gesture.source.removeAttribute('popover');
1640
- gesture.source.classList.remove('dragging');
2540
+ gesture.source.classList.remove('dragging', 'resourceDragging');
1641
2541
  for (const property of [
1642
2542
  '--drag-preview-width',
1643
2543
  '--drag-preview-height',
1644
2544
  '--drag-preview-x',
1645
2545
  '--drag-preview-y',
2546
+ '--resource-drag-preview-width',
2547
+ '--resource-drag-preview-x',
2548
+ '--resource-drag-preview-y',
1646
2549
  ]) gesture.source.style.removeProperty(property);
1647
2550
  this.draggingSessionId = '';
2551
+ this.draggingResourceId = '';
2552
+ this.resourceDropTargetSessionId = '';
1648
2553
  this.draggedItemHeight = 0;
1649
2554
  this.dropSlot = undefined;
1650
2555
  try {
@@ -1654,11 +2559,18 @@ export class DeesHarnessSessionList extends DeesElement {
1654
2559
  } catch {
1655
2560
  // Capture may already be released or the handle may have disconnected.
1656
2561
  }
1657
- if (detail) {
2562
+ if (sessionMoveDetail) {
1658
2563
  this.dispatchEvent(
1659
- new CustomEvent('harness-session-move', { detail, bubbles: true, composed: true }),
2564
+ new CustomEvent('harness-session-move', {
2565
+ detail: sessionMoveDetail,
2566
+ bubbles: true,
2567
+ composed: true,
2568
+ }),
1660
2569
  );
1661
2570
  }
2571
+ if (resourceRequest) {
2572
+ this.requestResourceAssociation(resourceRequest.resourceId, resourceRequest.toSessionId, 'drop');
2573
+ }
1662
2574
  }
1663
2575
 
1664
2576
  private emit(name: string, session: IHarnessSessionMeta): void {