@design.estate/dees-catalog 6.6.0 → 6.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,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;
@@ -387,6 +447,12 @@ export class DeesHarnessSessionList extends DeesElement {
387
447
  --card-accent: var(--session-error);
388
448
  }
389
449
 
450
+ .item.resourceDropTarget {
451
+ outline: 2px solid var(--dees-color-accent-primary);
452
+ outline-offset: 2px;
453
+ box-shadow: 0 0 0 5px var(--dees-color-focus-ring), var(--dees-shadow-lg);
454
+ }
455
+
390
456
  @keyframes attentionPulse {
391
457
  0%, 100% {
392
458
  background: color-mix(in srgb, var(--dees-color-bg-primary) 94%, var(--session-attention) 6%);
@@ -561,6 +627,183 @@ export class DeesHarnessSessionList extends DeesElement {
561
627
  font-size: var(--dees-font-caption2-size, 10px);
562
628
  }
563
629
 
630
+ .resourceDropLabel {
631
+ margin: 0 10px 10px;
632
+ padding: 6px 9px;
633
+ border-radius: var(--dees-radius-md);
634
+ background: color-mix(in srgb, var(--dees-color-accent-primary) 14%, transparent);
635
+ color: var(--dees-color-accent-primary);
636
+ font-size: var(--dees-font-caption1-size, 11px);
637
+ font-weight: 680;
638
+ text-align: center;
639
+ }
640
+
641
+ .resourceChildren {
642
+ display: grid;
643
+ gap: 4px;
644
+ margin: 0 8px 8px 24px;
645
+ padding: 0;
646
+ list-style: none;
647
+ }
648
+
649
+ .resourceSection {
650
+ margin-top: var(--dees-spacing-md);
651
+ }
652
+
653
+ .resourceSectionTitle {
654
+ margin: 0 0 7px;
655
+ padding: var(--dees-spacing-xs) 6px;
656
+ color: var(--dees-color-text-secondary);
657
+ font-size: var(--dees-font-caption1-size, 11px);
658
+ font-weight: 650;
659
+ letter-spacing: 0.05em;
660
+ text-transform: uppercase;
661
+ }
662
+
663
+ .resourceList {
664
+ display: grid;
665
+ gap: var(--dees-spacing-sm);
666
+ margin: 0;
667
+ padding: 0;
668
+ list-style: none;
669
+ }
670
+
671
+ .resourceRow {
672
+ display: grid;
673
+ grid-template-columns: 26px minmax(0, 1fr) 38px;
674
+ min-width: 0;
675
+ overflow: hidden;
676
+ border: 1px solid var(--dees-color-border-subtle);
677
+ border-radius: var(--dees-radius-lg);
678
+ background: color-mix(in srgb, var(--dees-color-bg-secondary) 76%, transparent);
679
+ color: var(--dees-color-text-primary);
680
+ box-shadow: var(--dees-shadow-xs);
681
+ box-sizing: border-box;
682
+ }
683
+
684
+ .resourceRow.selected {
685
+ border-color: var(--dees-color-accent-primary);
686
+ background: color-mix(in srgb, var(--dees-color-accent-primary) 10%, var(--dees-color-bg-secondary));
687
+ }
688
+
689
+ .resourceRow.resourceDragging {
690
+ position: fixed;
691
+ inset: 0 auto auto 0;
692
+ width: var(--resource-drag-preview-width);
693
+ max-width: none;
694
+ margin: 0;
695
+ opacity: 0.96;
696
+ pointer-events: none;
697
+ transform: translate3d(var(--resource-drag-preview-x), var(--resource-drag-preview-y), 0);
698
+ box-shadow: var(--dees-shadow-xl);
699
+ }
700
+
701
+ .resourceDragHandle,
702
+ .resourcePrimary,
703
+ .resourceActions {
704
+ appearance: none;
705
+ border: 0;
706
+ background: transparent;
707
+ color: inherit;
708
+ font-family: inherit;
709
+ }
710
+
711
+ .resourceDragHandle {
712
+ display: grid;
713
+ place-items: center;
714
+ min-width: 26px;
715
+ padding: 0;
716
+ color: var(--dees-color-text-muted);
717
+ cursor: grab;
718
+ touch-action: none;
719
+ }
720
+
721
+ .resourceDragHandle:disabled {
722
+ opacity: 0.35;
723
+ cursor: default;
724
+ }
725
+
726
+ .resourceDragHandle:not(:disabled):active {
727
+ cursor: grabbing;
728
+ }
729
+
730
+ .resourcePrimary {
731
+ display: flex;
732
+ min-width: 0;
733
+ min-height: 38px;
734
+ align-items: center;
735
+ gap: 7px;
736
+ padding: 5px 4px 5px 7px;
737
+ text-align: left;
738
+ cursor: pointer;
739
+ }
740
+
741
+ .resourceIcon {
742
+ flex: 0 0 auto;
743
+ color: var(--dees-color-text-secondary);
744
+ }
745
+
746
+ .resourceCopy {
747
+ min-width: 0;
748
+ flex: 1;
749
+ }
750
+
751
+ .resourceTitle {
752
+ display: block;
753
+ overflow: hidden;
754
+ color: var(--dees-color-text-primary);
755
+ font-size: var(--dees-font-caption1-size, 11px);
756
+ font-weight: 620;
757
+ text-overflow: ellipsis;
758
+ white-space: nowrap;
759
+ }
760
+
761
+ .resourcePreview {
762
+ display: block;
763
+ overflow: hidden;
764
+ margin-top: 1px;
765
+ color: var(--dees-color-text-secondary);
766
+ font-size: var(--dees-font-caption2-size, 10px);
767
+ text-overflow: ellipsis;
768
+ white-space: nowrap;
769
+ }
770
+
771
+ .resourceMeta {
772
+ display: block;
773
+ overflow: hidden;
774
+ margin-top: 1px;
775
+ color: var(--dees-color-text-muted);
776
+ font-size: var(--dees-font-caption2-size, 10px);
777
+ text-overflow: ellipsis;
778
+ white-space: nowrap;
779
+ }
780
+
781
+ .resourceActions {
782
+ display: grid;
783
+ place-items: center;
784
+ min-width: 34px;
785
+ min-height: 34px;
786
+ margin: 2px;
787
+ padding: 0;
788
+ border-radius: var(--dees-radius-md);
789
+ color: var(--dees-color-text-muted);
790
+ cursor: pointer;
791
+ }
792
+
793
+ .resourceActions:hover {
794
+ background: var(--dees-color-hover);
795
+ color: var(--dees-color-text-primary);
796
+ }
797
+
798
+ .resourceDragHandle:focus-visible,
799
+ .resourcePrimary:focus-visible,
800
+ .resourceActions:focus-visible {
801
+ z-index: 1;
802
+ outline: 2px solid var(--dees-color-accent-primary);
803
+ outline-offset: -3px;
804
+ border-radius: var(--dees-radius-md);
805
+ }
806
+
564
807
  .visuallyHidden {
565
808
  position: absolute;
566
809
  width: 1px;
@@ -687,6 +930,33 @@ export class DeesHarnessSessionList extends DeesElement {
687
930
  transition: none;
688
931
  }
689
932
  }
933
+
934
+ @media (pointer: coarse) {
935
+ .resourceRow {
936
+ grid-template-columns: 44px minmax(0, 1fr) 44px;
937
+ }
938
+
939
+ .resourceDragHandle,
940
+ .resourceActions {
941
+ min-width: 44px;
942
+ min-height: 44px;
943
+ margin: 0;
944
+ }
945
+
946
+ .resourcePrimary {
947
+ min-height: 44px;
948
+ }
949
+ }
950
+
951
+ @media (max-width: 280px) {
952
+ .resourceChildren {
953
+ margin-left: 8px;
954
+ }
955
+
956
+ .resourcePreview {
957
+ display: none;
958
+ }
959
+ }
690
960
  `,
691
961
  ];
692
962
 
@@ -694,6 +964,70 @@ export class DeesHarnessSessionList extends DeesElement {
694
964
  return new Map(this.sessions.map((session) => [session.id, session]));
695
965
  }
696
966
 
967
+ private get resourcesById(): Map<string, IHarnessResourceMeta> {
968
+ const result = new Map<string, IHarnessResourceMeta>();
969
+ for (const resource of this.resources) {
970
+ if (!resource.id || !resource.kind || !resource.title || result.has(resource.id)) continue;
971
+ result.set(resource.id, resource);
972
+ }
973
+ return result;
974
+ }
975
+
976
+ /** First valid record wins; unknown and duplicate records remain untouched but are not projected. */
977
+ private get acceptedResourceAssociations(): Map<string, string> {
978
+ const resourcesById = this.resourcesById;
979
+ const sessionsById = this.sessionsById;
980
+ const result = new Map<string, string>();
981
+ for (const association of this.resourceAssociations) {
982
+ if (
983
+ !resourcesById.has(association.resourceId)
984
+ || !sessionsById.has(association.sessionId)
985
+ || result.has(association.resourceId)
986
+ ) continue;
987
+ result.set(association.resourceId, association.sessionId);
988
+ }
989
+ return result;
990
+ }
991
+
992
+ private get acceptedResources(): IHarnessResourceMeta[] {
993
+ return [...this.resourcesById.values()];
994
+ }
995
+
996
+ private resourceMatchesSearch(resourceArg: IHarnessResourceMeta, queryArg: string): boolean {
997
+ return [resourceArg.id, resourceArg.kind, resourceArg.title, resourceArg.preview]
998
+ .filter(Boolean)
999
+ .join(' ')
1000
+ .toLowerCase()
1001
+ .includes(queryArg);
1002
+ }
1003
+
1004
+ private sessionMatchesSearch(sessionArg: IHarnessSessionMeta, queryArg: string): boolean {
1005
+ return [sessionArg.title, sessionArg.preview, sessionArg.id]
1006
+ .filter(Boolean)
1007
+ .join(' ')
1008
+ .toLowerCase()
1009
+ .includes(queryArg);
1010
+ }
1011
+
1012
+ private resourcesForSession(sessionArg: IHarnessSessionMeta): IHarnessResourceMeta[] {
1013
+ const associations = this.acceptedResourceAssociations;
1014
+ const associated = this.acceptedResources.filter(
1015
+ (resource) => associations.get(resource.id) === sessionArg.id,
1016
+ );
1017
+ const query = this.search.trim().toLowerCase();
1018
+ if (!query || this.sessionMatchesSearch(sessionArg, query)) return associated;
1019
+ return associated.filter((resource) => this.resourceMatchesSearch(resource, query));
1020
+ }
1021
+
1022
+ private get unattachedResources(): IHarnessResourceMeta[] {
1023
+ const associations = this.acceptedResourceAssociations;
1024
+ const query = this.search.trim().toLowerCase();
1025
+ return this.acceptedResources.filter((resource) => (
1026
+ !associations.has(resource.id)
1027
+ && (!query || this.resourceMatchesSearch(resource, query))
1028
+ ));
1029
+ }
1030
+
697
1031
  private get groupedSessionIds(): Set<string> {
698
1032
  const ids = new Set<string>();
699
1033
  for (const group of this.groups) {
@@ -726,12 +1060,19 @@ export class DeesHarnessSessionList extends DeesElement {
726
1060
 
727
1061
  private get filteredSessions(): IHarnessSessionMeta[] {
728
1062
  const query = this.search.trim().toLowerCase();
1063
+ const associations = this.acceptedResourceAssociations;
1064
+ const matchingAttachedSessionIds = new Set(
1065
+ this.acceptedResources
1066
+ .filter((resource) => query && this.resourceMatchesSearch(resource, query))
1067
+ .map((resource) => associations.get(resource.id))
1068
+ .filter((sessionId): sessionId is string => sessionId !== undefined),
1069
+ );
729
1070
  const sorted = [...this.sessions].sort(
730
1071
  (left, right) => (right.updatedAt ?? right.createdAt ?? 0) - (left.updatedAt ?? left.createdAt ?? 0),
731
1072
  );
732
1073
  if (!query) return sorted;
733
- return sorted.filter((session) =>
734
- [session.title, session.preview, session.id].filter(Boolean).join(' ').toLowerCase().includes(query),
1074
+ return sorted.filter(
1075
+ (session) => this.sessionMatchesSearch(session, query) || matchingAttachedSessionIds.has(session.id),
735
1076
  );
736
1077
  }
737
1078
 
@@ -739,6 +1080,61 @@ export class DeesHarnessSessionList extends DeesElement {
739
1080
  this.shadowRoot?.querySelector<HTMLInputElement>('.searchRow input')?.focus();
740
1081
  }
741
1082
 
1083
+ public requestResourceAssociation(
1084
+ resourceIdArg: string,
1085
+ toSessionIdArg: string | null,
1086
+ sourceArg: THarnessResourceAssociationInteractionSource = 'action',
1087
+ ): boolean {
1088
+ const resource = this.resourcesById.get(resourceIdArg);
1089
+ if (!resource) return false;
1090
+ const fromSessionId = this.acceptedResourceAssociations.get(resourceIdArg) ?? null;
1091
+ if (fromSessionId === toSessionIdArg) return false;
1092
+ if (
1093
+ toSessionIdArg !== null
1094
+ && (!this.sessionsById.has(toSessionIdArg) || !this.isResourceTargetEligible(resourceIdArg, toSessionIdArg))
1095
+ ) return false;
1096
+ if (toSessionIdArg === null && fromSessionId === null) return false;
1097
+ const detail: THarnessResourceAssociationRequestDetail = fromSessionId === null
1098
+ ? {
1099
+ intent: 'attach',
1100
+ resource,
1101
+ fromSessionId: null,
1102
+ toSessionId: toSessionIdArg!,
1103
+ source: sourceArg,
1104
+ }
1105
+ : toSessionIdArg === null
1106
+ ? {
1107
+ intent: 'detach',
1108
+ resource,
1109
+ fromSessionId,
1110
+ toSessionId: null,
1111
+ source: sourceArg,
1112
+ }
1113
+ : {
1114
+ intent: 'reassign',
1115
+ resource,
1116
+ fromSessionId,
1117
+ toSessionId: toSessionIdArg,
1118
+ source: sourceArg,
1119
+ };
1120
+ this.pendingResourceAssociationRequest = {
1121
+ resourceId: resource.id,
1122
+ resourceTitle: resource.title || resource.id,
1123
+ fromSessionId,
1124
+ toSessionId: toSessionIdArg,
1125
+ intent: detail.intent,
1126
+ };
1127
+ this.moveAnnouncement = this.resourceAssociationAnnouncement(detail, false);
1128
+ this.dispatchEvent(
1129
+ new CustomEvent('harness-resource-association-request', {
1130
+ detail,
1131
+ bubbles: true,
1132
+ composed: true,
1133
+ }),
1134
+ );
1135
+ return true;
1136
+ }
1137
+
742
1138
  public updated(changedProperties: Map<PropertyKey, unknown>): void {
743
1139
  super.updated(changedProperties);
744
1140
  this.observeScrollGeometry();
@@ -750,19 +1146,41 @@ export class DeesHarnessSessionList extends DeesElement {
750
1146
  ) {
751
1147
  this.finishPointerDrag(false);
752
1148
  }
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);
1149
+ if (changedProperties.has('sessions')) {
1150
+ const knownIds = new Set(this.sessions.map((session) => session.id));
1151
+ if (
1152
+ this.pointerDragGesture?.type === 'session'
1153
+ && !knownIds.has(this.pointerDragGesture.sessionId)
1154
+ ) {
1155
+ this.finishPointerDrag(false);
1156
+ }
1157
+ const nextExpanded = new Set(
1158
+ [...this.expandedSessionIds].filter((sessionId) => knownIds.has(sessionId)),
1159
+ );
1160
+ if (
1161
+ nextExpanded.size !== this.expandedSessionIds.size
1162
+ || [...nextExpanded].some((sessionId) => !this.expandedSessionIds.has(sessionId))
1163
+ ) {
1164
+ this.expandedSessionIds = nextExpanded;
1165
+ }
757
1166
  }
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;
1167
+ if (changedProperties.has('resources') || changedProperties.has('resourceAssociations')) {
1168
+ const gesture = this.pointerDragGesture;
1169
+ if (gesture?.type === 'resource') {
1170
+ const currentSessionId = this.acceptedResourceAssociations.get(gesture.resourceId) ?? null;
1171
+ if (!this.resourcesById.has(gesture.resourceId) || currentSessionId !== gesture.fromSessionId) {
1172
+ this.finishPointerDrag(false);
1173
+ }
1174
+ }
1175
+ if (
1176
+ this.resourceContextResourceId
1177
+ && !this.resourcesById.has(this.resourceContextResourceId)
1178
+ ) this.closeResourceContext(this.resourceContextGeneration);
1179
+ this.reconcilePendingResourceAssociation();
1180
+ }
1181
+ if (changedProperties.has('eligibleSessionIdsByResourceId')) {
1182
+ const gesture = this.pointerDragGesture;
1183
+ if (gesture?.type === 'resource') this.finishPointerDrag(false);
766
1184
  }
767
1185
  }
768
1186
 
@@ -776,6 +1194,7 @@ export class DeesHarnessSessionList extends DeesElement {
776
1194
 
777
1195
  public async disconnectedCallback(): Promise<void> {
778
1196
  this.finishPointerDrag(false);
1197
+ this.clearResourceContextState();
779
1198
  this.scrollResizeObserver?.disconnect();
780
1199
  this.scrollResizeObserver = undefined;
781
1200
  this.observedScrollContainer = undefined;
@@ -789,7 +1208,12 @@ export class DeesHarnessSessionList extends DeesElement {
789
1208
  <div class="searchRow">
790
1209
  <dees-icon class="searchIcon" icon="lucide:Search" iconSize="13"></dees-icon>
791
1210
  <input
792
- placeholder="Search conversations"
1211
+ placeholder=${this.acceptedResources.length
1212
+ ? 'Search conversations and resources'
1213
+ : 'Search conversations'}
1214
+ aria-label=${this.acceptedResources.length
1215
+ ? 'Search conversations and resources'
1216
+ : 'Search conversations'}
793
1217
  .value=${this.search}
794
1218
  @input=${(event: Event) => {
795
1219
  this.search = (event.currentTarget as HTMLInputElement).value;
@@ -839,9 +1263,16 @@ export class DeesHarnessSessionList extends DeesElement {
839
1263
 
840
1264
  private renderFlatList(): TemplateResult {
841
1265
  const sessions = this.filteredSessions;
842
- if (!sessions.length) {
1266
+ const unattachedResources = this.unattachedResources;
1267
+ if (!sessions.length && !unattachedResources.length) {
843
1268
  return html`<div class="emptyNote">
844
- ${this.sessions.length ? 'No conversations match this search.' : this.emptyText}
1269
+ ${this.acceptedResources.length
1270
+ ? this.sessions.length
1271
+ ? 'No conversations or resources match this search.'
1272
+ : 'No resources match this search.'
1273
+ : this.sessions.length
1274
+ ? 'No conversations match this search.'
1275
+ : this.emptyText}
845
1276
  </div>`;
846
1277
  }
847
1278
  return html`
@@ -852,13 +1283,15 @@ export class DeesHarnessSessionList extends DeesElement {
852
1283
  (session) => this.renderItem(session, null, -1),
853
1284
  )}
854
1285
  </div>
1286
+ ${this.renderUnattachedResources(unattachedResources)}
855
1287
  `;
856
1288
  }
857
1289
 
858
1290
  private renderGroupedList(): TemplateResult {
859
1291
  const byId = this.sessionsById;
860
1292
  const ungrouped = this.ungroupedSessions;
861
- if (!this.sessions.length && !this.groups.length) {
1293
+ const unattachedResources = this.unattachedResources;
1294
+ if (!this.sessions.length && !this.groups.length && !unattachedResources.length) {
862
1295
  return html`<div class="emptyNote">${this.emptyText}</div>`;
863
1296
  }
864
1297
  return html`
@@ -917,6 +1350,7 @@ export class DeesHarnessSessionList extends DeesElement {
917
1350
  `
918
1351
  : ''}
919
1352
  ${this.renderSection(ungrouped, null)}
1353
+ ${this.renderUnattachedResources(unattachedResources)}
920
1354
  `;
921
1355
  }
922
1356
 
@@ -1000,12 +1434,16 @@ export class DeesHarnessSessionList extends DeesElement {
1000
1434
  const state = session.state ?? 'normal';
1001
1435
  const detailsId = `session-details-${session.id}`;
1002
1436
  const dropIndex = index < 0 ? 0 : index;
1437
+ const resources = this.resourcesForSession(session);
1438
+ const isResourceDropTarget = this.resourceDropTargetSessionId === session.id;
1003
1439
  return html`
1004
1440
  <article
1005
1441
  class="item state-${state} ${expanded ? 'expanded' : ''} ${
1006
1442
  session.id === this.selectedSessionId ? 'selected' : ''
1007
1443
  } ${
1008
1444
  this.draggingSessionId === session.id ? 'dragging' : ''
1445
+ } ${
1446
+ isResourceDropTarget ? 'resourceDropTarget' : ''
1009
1447
  }"
1010
1448
  data-session-id=${session.id}
1011
1449
  data-drop-group-id=${groupId ?? ''}
@@ -1091,6 +1529,20 @@ export class DeesHarnessSessionList extends DeesElement {
1091
1529
  <dees-icon icon="lucide:ChevronDown" iconSize="15"></dees-icon>
1092
1530
  </button>
1093
1531
  </div>
1532
+ ${isResourceDropTarget
1533
+ ? html`<div class="resourceDropLabel">Associate resource with ${session.title || session.id}</div>`
1534
+ : ''}
1535
+ ${resources.length
1536
+ ? html`
1537
+ <ul class="resourceChildren" aria-label=${`Resources for ${session.title || session.id}`}>
1538
+ ${repeat(
1539
+ resources,
1540
+ (resource) => resource.id,
1541
+ (resource) => this.renderResource(resource, session.id),
1542
+ )}
1543
+ </ul>
1544
+ `
1545
+ : ''}
1094
1546
  ${expanded
1095
1547
  ? html`
1096
1548
  <div class="itemDetails" id=${detailsId}>
@@ -1110,6 +1562,129 @@ export class DeesHarnessSessionList extends DeesElement {
1110
1562
  `;
1111
1563
  }
1112
1564
 
1565
+ private renderUnattachedResources(resourcesArg: IHarnessResourceMeta[]): TemplateResult {
1566
+ if (!resourcesArg.length) return html``;
1567
+ return html`
1568
+ <section class="resourceSection" aria-labelledby="unattached-resources-heading">
1569
+ <h2 class="resourceSectionTitle" id="unattached-resources-heading">Resources</h2>
1570
+ <ul class="resourceList" aria-label="Unattached resources">
1571
+ ${repeat(
1572
+ resourcesArg,
1573
+ (resource) => resource.id,
1574
+ (resource) => this.renderResource(resource, null),
1575
+ )}
1576
+ </ul>
1577
+ </section>
1578
+ `;
1579
+ }
1580
+
1581
+ private renderResource(
1582
+ resourceArg: IHarnessResourceMeta,
1583
+ sessionIdArg: string | null,
1584
+ ): TemplateResult {
1585
+ const selected = resourceArg.id === this.selectedResourceId;
1586
+ const dragging = resourceArg.id === this.draggingResourceId;
1587
+ const eligibleTargetCount = this.eligibleSessionIds(resourceArg.id).filter(
1588
+ (sessionId) => sessionId !== sessionIdArg,
1589
+ ).length;
1590
+ const contextOpen = resourceArg.id === this.resourceContextResourceId;
1591
+ return html`
1592
+ <li
1593
+ class="resourceRow ${selected ? 'selected' : ''} ${dragging ? 'resourceDragging' : ''}"
1594
+ data-resource-id=${resourceArg.id}
1595
+ data-resource-session-id=${sessionIdArg ?? ''}
1596
+ @dblclick=${(event: MouseEvent) => {
1597
+ event.stopPropagation();
1598
+ if (this.eventTargetsResourceControl(event)) return;
1599
+ this.emitResource('harness-resource-open', resourceArg, sessionIdArg);
1600
+ }}
1601
+ @contextmenu=${(event: MouseEvent) => {
1602
+ event.preventDefault();
1603
+ event.stopPropagation();
1604
+ const focusTarget = (event.composedPath().find((target) => (
1605
+ target instanceof HTMLElement
1606
+ && target.matches('button:not(:disabled)')
1607
+ )) as HTMLElement | undefined)
1608
+ ?? (event.currentTarget as HTMLElement).querySelector<HTMLElement>('.resourcePrimary')
1609
+ ?? event.currentTarget as HTMLElement;
1610
+ this.emitResourceContext(event, resourceArg, sessionIdArg, 'pointer', focusTarget);
1611
+ }}
1612
+ >
1613
+ <button
1614
+ class="resourceDragHandle"
1615
+ type="button"
1616
+ title=${eligibleTargetCount
1617
+ ? 'Drag to associate with an eligible conversation'
1618
+ : 'No eligible conversations'}
1619
+ aria-label=${`Associate ${resourceArg.title || resourceArg.id} by dragging`}
1620
+ aria-keyshortcuts="ContextMenu Shift+F10"
1621
+ ?disabled=${eligibleTargetCount === 0}
1622
+ @pointerdown=${(event: PointerEvent) => {
1623
+ this.handleResourcePointerDown(event, resourceArg, sessionIdArg);
1624
+ }}
1625
+ @pointermove=${this.handlePointerMove}
1626
+ @pointerup=${this.handlePointerUp}
1627
+ @pointercancel=${this.handlePointerCancel}
1628
+ @lostpointercapture=${this.handleLostPointerCapture}
1629
+ @keydown=${(event: KeyboardEvent) => {
1630
+ this.handleResourceContextKey(event, resourceArg, sessionIdArg);
1631
+ }}
1632
+ >
1633
+ <dees-icon icon="lucide:GripVertical" iconSize="13"></dees-icon>
1634
+ </button>
1635
+ <button
1636
+ class="resourcePrimary"
1637
+ type="button"
1638
+ aria-current=${selected ? 'true' : 'false'}
1639
+ @click=${() => this.emitResource('harness-resource-select', resourceArg, sessionIdArg)}
1640
+ @keydown=${(event: KeyboardEvent) => {
1641
+ if (event.key === 'Enter') {
1642
+ event.preventDefault();
1643
+ this.emitResource('harness-resource-open', resourceArg, sessionIdArg);
1644
+ return;
1645
+ }
1646
+ this.handleResourceContextKey(event, resourceArg, sessionIdArg);
1647
+ }}
1648
+ >
1649
+ <dees-icon
1650
+ class="resourceIcon"
1651
+ icon=${resourceArg.icon || 'lucide:Box'}
1652
+ iconSize="14"
1653
+ ></dees-icon>
1654
+ <span class="resourceCopy">
1655
+ <span class="resourceTitle">${resourceArg.title || resourceArg.id}</span>
1656
+ ${resourceArg.preview ? html`<span class="resourcePreview">${resourceArg.preview}</span>` : ''}
1657
+ <span class="resourceMeta">
1658
+ ${resourceArg.kind}${resourceArg.state ? ` · ${resourceArg.state}` : ''}${
1659
+ resourceArg.updatedAt || resourceArg.createdAt
1660
+ ? ` · ${harnessFormatRelativeTime(resourceArg.updatedAt ?? resourceArg.createdAt)}`
1661
+ : ''
1662
+ }
1663
+ </span>
1664
+ </span>
1665
+ </button>
1666
+ <button
1667
+ class="resourceActions"
1668
+ type="button"
1669
+ title="Resource actions"
1670
+ aria-label=${`Actions for ${resourceArg.title || resourceArg.id}`}
1671
+ aria-haspopup="menu"
1672
+ aria-expanded=${contextOpen ? 'true' : 'false'}
1673
+ aria-keyshortcuts="ContextMenu Shift+F10"
1674
+ @click=${(event: MouseEvent) => {
1675
+ event.stopPropagation();
1676
+ this.emitResourceContext(event, resourceArg, sessionIdArg, 'action', event.currentTarget as HTMLElement);
1677
+ }}
1678
+ @keydown=${(event: KeyboardEvent) => {
1679
+ this.handleResourceContextKey(event, resourceArg, sessionIdArg);
1680
+ }}
1681
+ >
1682
+ <dees-icon icon="lucide:Ellipsis" iconSize="15"></dees-icon>
1683
+ </button>
1684
+ </li>
1685
+ `;
1686
+ }
1687
+
1113
1688
  private eventTargetsExpandToggle(event: Event): boolean {
1114
1689
  return event.composedPath().some(
1115
1690
  (target) => target instanceof HTMLElement && target.classList.contains('expandToggle'),
@@ -1122,6 +1697,15 @@ export class DeesHarnessSessionList extends DeesElement {
1122
1697
  );
1123
1698
  }
1124
1699
 
1700
+ private eventTargetsResourceControl(event: Event): boolean {
1701
+ return event.composedPath().some(
1702
+ (target) => target instanceof HTMLElement && (
1703
+ target.classList.contains('resourceDragHandle')
1704
+ || target.classList.contains('resourceActions')
1705
+ ),
1706
+ );
1707
+ }
1708
+
1125
1709
  private sessionStateLabel(state: NonNullable<IHarnessSessionMeta['state']>): string {
1126
1710
  switch (state) {
1127
1711
  case 'working': return 'Working';
@@ -1186,6 +1770,7 @@ export class DeesHarnessSessionList extends DeesElement {
1186
1770
  if (!source) return;
1187
1771
  const sourceRect = source.getBoundingClientRect();
1188
1772
  this.pointerDragGesture = {
1773
+ type: 'session',
1189
1774
  pointerId: eventArg.pointerId,
1190
1775
  handle,
1191
1776
  source,
@@ -1207,6 +1792,43 @@ export class DeesHarnessSessionList extends DeesElement {
1207
1792
  }
1208
1793
  }
1209
1794
 
1795
+ private handleResourcePointerDown(
1796
+ eventArg: PointerEvent,
1797
+ resourceArg: IHarnessResourceMeta,
1798
+ fromSessionIdArg: string | null,
1799
+ ): void {
1800
+ if (
1801
+ this.pointerDragGesture
1802
+ || eventArg.button !== 0
1803
+ || !eventArg.isPrimary
1804
+ || this.eligibleSessionIds(resourceArg.id).every((sessionId) => sessionId === fromSessionIdArg)
1805
+ ) return;
1806
+ const handle = eventArg.currentTarget as HTMLElement;
1807
+ const source = handle.closest<HTMLElement>('.resourceRow');
1808
+ if (!source) return;
1809
+ const sourceRect = source.getBoundingClientRect();
1810
+ this.pointerDragGesture = {
1811
+ type: 'resource',
1812
+ pointerId: eventArg.pointerId,
1813
+ handle,
1814
+ source,
1815
+ resourceId: resourceArg.id,
1816
+ fromSessionId: fromSessionIdArg,
1817
+ startClientX: eventArg.clientX,
1818
+ startClientY: eventArg.clientY,
1819
+ grabOffsetX: eventArg.clientX - sourceRect.left,
1820
+ grabOffsetY: eventArg.clientY - sourceRect.top,
1821
+ phase: 'pending',
1822
+ };
1823
+ window.addEventListener('keydown', this.handleDragKeyDown, true);
1824
+ window.addEventListener('blur', this.handleDragWindowBlur);
1825
+ try {
1826
+ handle.setPointerCapture(eventArg.pointerId);
1827
+ } catch {
1828
+ this.finishPointerDrag(false);
1829
+ }
1830
+ }
1831
+
1210
1832
  private readonly handlePointerMove = (eventArg: PointerEvent): void => {
1211
1833
  const gesture = this.pointerDragGesture;
1212
1834
  if (!gesture || eventArg.pointerId !== gesture.pointerId) return;
@@ -1221,8 +1843,21 @@ export class DeesHarnessSessionList extends DeesElement {
1221
1843
  eventArg.clientY - gesture.startClientY,
1222
1844
  ) < dragThresholdPixels
1223
1845
  ) return;
1224
- if (gesture.phase === 'pending' && !this.startPointerDrag(eventArg, gesture)) return;
1846
+ if (
1847
+ gesture.phase === 'pending'
1848
+ && !(gesture.type === 'session'
1849
+ ? this.startPointerDrag(eventArg, gesture)
1850
+ : this.startResourcePointerDrag(eventArg, gesture))
1851
+ ) return;
1225
1852
  eventArg.preventDefault();
1853
+ if (gesture.type === 'resource') {
1854
+ this.moveResourceDragPreview(eventArg.clientX, eventArg.clientY, gesture);
1855
+ this.dragAutoScrollClientX = eventArg.clientX;
1856
+ this.dragAutoScrollClientY = eventArg.clientY;
1857
+ this.updateDragAutoScrollVelocity();
1858
+ this.updateResourceDropTargetFromPointer(eventArg.clientX, eventArg.clientY);
1859
+ return;
1860
+ }
1226
1861
  this.moveDragPreview(eventArg.clientX, eventArg.clientY, gesture);
1227
1862
  this.dragAutoScrollClientX = eventArg.clientX;
1228
1863
  this.dragAutoScrollClientY = eventArg.clientY;
@@ -1234,6 +1869,14 @@ export class DeesHarnessSessionList extends DeesElement {
1234
1869
  const gesture = this.pointerDragGesture;
1235
1870
  if (!gesture || eventArg.pointerId !== gesture.pointerId) return;
1236
1871
  if (gesture.phase === 'active') {
1872
+ if (gesture.type === 'resource') {
1873
+ this.dragAutoScrollClientX = eventArg.clientX;
1874
+ this.dragAutoScrollClientY = eventArg.clientY;
1875
+ this.moveResourceDragPreview(eventArg.clientX, eventArg.clientY, gesture);
1876
+ this.updateResourceDropTargetFromPointer(eventArg.clientX, eventArg.clientY);
1877
+ this.finishPointerDrag(true);
1878
+ return;
1879
+ }
1237
1880
  this.dragAutoScrollClientX = eventArg.clientX;
1238
1881
  this.dragAutoScrollClientY = eventArg.clientY;
1239
1882
  this.moveDragPreview(eventArg.clientX, eventArg.clientY, gesture);
@@ -1289,6 +1932,27 @@ export class DeesHarnessSessionList extends DeesElement {
1289
1932
  return true;
1290
1933
  }
1291
1934
 
1935
+ private startResourcePointerDrag(
1936
+ eventArg: PointerEvent,
1937
+ gestureArg: IResourcePointerDragGesture,
1938
+ ): boolean {
1939
+ const sourceRect = gestureArg.source.getBoundingClientRect();
1940
+ gestureArg.source.classList.add('resourceDragging');
1941
+ gestureArg.source.style.setProperty('--resource-drag-preview-width', `${sourceRect.width}px`);
1942
+ this.moveResourceDragPreview(eventArg.clientX, eventArg.clientY, gestureArg);
1943
+ gestureArg.source.setAttribute('popover', 'manual');
1944
+ try {
1945
+ gestureArg.source.showPopover();
1946
+ } catch {
1947
+ this.finishPointerDrag(false);
1948
+ return false;
1949
+ }
1950
+ gestureArg.phase = 'active';
1951
+ this.draggingResourceId = gestureArg.resourceId;
1952
+ this.resourceDropTargetSessionId = '';
1953
+ return true;
1954
+ }
1955
+
1292
1956
  private moveDragPreview(
1293
1957
  clientXArg: number,
1294
1958
  clientYArg: number,
@@ -1304,6 +1968,34 @@ export class DeesHarnessSessionList extends DeesElement {
1304
1968
  );
1305
1969
  }
1306
1970
 
1971
+ private moveResourceDragPreview(
1972
+ clientXArg: number,
1973
+ clientYArg: number,
1974
+ gestureArg: IResourcePointerDragGesture,
1975
+ ): void {
1976
+ gestureArg.source.style.setProperty(
1977
+ '--resource-drag-preview-x',
1978
+ `${clientXArg - gestureArg.grabOffsetX}px`,
1979
+ );
1980
+ gestureArg.source.style.setProperty(
1981
+ '--resource-drag-preview-y',
1982
+ `${clientYArg - gestureArg.grabOffsetY}px`,
1983
+ );
1984
+ }
1985
+
1986
+ private updateResourceDropTargetFromPointer(clientXArg: number, clientYArg: number): void {
1987
+ const gesture = this.pointerDragGesture;
1988
+ if (gesture?.type !== 'resource' || gesture.phase !== 'active') return;
1989
+ const hit = this.shadowRoot?.elementFromPoint(clientXArg, clientYArg);
1990
+ const target = hit?.closest<HTMLElement>('.item[data-session-id]');
1991
+ const sessionId = target?.dataset.sessionId ?? '';
1992
+ this.resourceDropTargetSessionId = (
1993
+ sessionId
1994
+ && sessionId !== gesture.fromSessionId
1995
+ && this.isResourceTargetEligible(gesture.resourceId, sessionId)
1996
+ ) ? sessionId : '';
1997
+ }
1998
+
1307
1999
  private setDropSlot(groupIdArg: string | null, indexArg: number): void {
1308
2000
  const current = this.dropSlot;
1309
2001
  if (current && current.groupId === groupIdArg && current.index === indexArg) return;
@@ -1323,7 +2015,8 @@ export class DeesHarnessSessionList extends DeesElement {
1323
2015
 
1324
2016
  private updateDragAutoScrollVelocity(): void {
1325
2017
  const container = this.scrollContainer;
1326
- if (!container || !this.draggingSessionId) return;
2018
+ const gesture = this.pointerDragGesture;
2019
+ if (!container || gesture?.phase !== 'active') return;
1327
2020
  const rect = container.getBoundingClientRect();
1328
2021
  if (this.dragAutoScrollClientX < rect.left || this.dragAutoScrollClientX > rect.right) {
1329
2022
  this.stopDragAutoScroll();
@@ -1356,7 +2049,8 @@ export class DeesHarnessSessionList extends DeesElement {
1356
2049
  private readonly runDragAutoScroll = (timestampArg: number): void => {
1357
2050
  this.dragAutoScrollFrame = undefined;
1358
2051
  const container = this.scrollContainer;
1359
- if (!container || !this.draggingSessionId || this.dragAutoScrollVelocity === 0) return;
2052
+ const gesture = this.pointerDragGesture;
2053
+ if (!container || gesture?.phase !== 'active' || this.dragAutoScrollVelocity === 0) return;
1360
2054
  const elapsedMs = Math.min(34, Math.max(0, timestampArg - this.dragAutoScrollLastFrame));
1361
2055
  this.dragAutoScrollLastFrame = timestampArg;
1362
2056
  const previousScrollTop = container.scrollTop;
@@ -1369,7 +2063,14 @@ export class DeesHarnessSessionList extends DeesElement {
1369
2063
  this.stopDragAutoScroll();
1370
2064
  return;
1371
2065
  }
1372
- this.updateDropSlotFromPointer();
2066
+ if (gesture.type === 'resource') {
2067
+ this.updateResourceDropTargetFromPointer(
2068
+ this.dragAutoScrollClientX,
2069
+ this.dragAutoScrollClientY,
2070
+ );
2071
+ } else {
2072
+ this.updateDropSlotFromPointer();
2073
+ }
1373
2074
  this.dragAutoScrollFrame = requestAnimationFrame(this.runDragAutoScroll);
1374
2075
  };
1375
2076
 
@@ -1585,6 +2286,171 @@ export class DeesHarnessSessionList extends DeesElement {
1585
2286
  }
1586
2287
  }
1587
2288
 
2289
+ private eligibleSessionIds(resourceIdArg: string): string[] {
2290
+ const knownSessionIds = new Set(this.sessions.map((session) => session.id));
2291
+ const result: string[] = [];
2292
+ const seen = new Set<string>();
2293
+ for (const sessionId of this.eligibleSessionIdsByResourceId[resourceIdArg] ?? []) {
2294
+ if (!knownSessionIds.has(sessionId) || seen.has(sessionId)) continue;
2295
+ seen.add(sessionId);
2296
+ result.push(sessionId);
2297
+ }
2298
+ return result;
2299
+ }
2300
+
2301
+ private isResourceTargetEligible(resourceIdArg: string, sessionIdArg: string): boolean {
2302
+ return this.eligibleSessionIds(resourceIdArg).includes(sessionIdArg);
2303
+ }
2304
+
2305
+ private emitResource(
2306
+ nameArg: string,
2307
+ resourceArg: IHarnessResourceMeta,
2308
+ sessionIdArg: string | null,
2309
+ ): void {
2310
+ const detail: IHarnessResourceEventDetail = {
2311
+ resource: resourceArg,
2312
+ sessionId: sessionIdArg,
2313
+ };
2314
+ this.dispatchEvent(new CustomEvent(nameArg, { detail, bubbles: true, composed: true }));
2315
+ }
2316
+
2317
+ private handleResourceContextKey(
2318
+ eventArg: KeyboardEvent,
2319
+ resourceArg: IHarnessResourceMeta,
2320
+ sessionIdArg: string | null,
2321
+ ): void {
2322
+ if (!(eventArg.key === 'ContextMenu' || (eventArg.key === 'F10' && eventArg.shiftKey))) return;
2323
+ eventArg.preventDefault();
2324
+ eventArg.stopPropagation();
2325
+ this.emitResourceContext(
2326
+ eventArg,
2327
+ resourceArg,
2328
+ sessionIdArg,
2329
+ 'keyboard',
2330
+ eventArg.currentTarget as HTMLElement,
2331
+ );
2332
+ }
2333
+
2334
+ private emitResourceContext(
2335
+ eventArg: MouseEvent | KeyboardEvent,
2336
+ resourceArg: IHarnessResourceMeta,
2337
+ sessionIdArg: string | null,
2338
+ sourceArg: THarnessResourceContextSource,
2339
+ focusTargetArg: HTMLElement,
2340
+ ): void {
2341
+ const rect = focusTargetArg.getBoundingClientRect();
2342
+ const contextGeneration = ++this.resourceContextGeneration;
2343
+ this.resourceContextResourceId = resourceArg.id;
2344
+ this.resourceContextFocusTarget = focusTargetArg;
2345
+ this.resourceContextFocusClass = [
2346
+ 'resourceActions',
2347
+ 'resourcePrimary',
2348
+ 'resourceDragHandle',
2349
+ ].find((className) => focusTargetArg.classList.contains(className)) ?? 'resourcePrimary';
2350
+ const detail: IHarnessResourceContextDetail = {
2351
+ resource: resourceArg,
2352
+ sessionId: sessionIdArg,
2353
+ clientX: sourceArg === 'pointer' && eventArg instanceof MouseEvent
2354
+ ? eventArg.clientX
2355
+ : rect.left + rect.width / 2,
2356
+ clientY: sourceArg === 'pointer' && eventArg instanceof MouseEvent
2357
+ ? eventArg.clientY
2358
+ : rect.bottom,
2359
+ source: sourceArg,
2360
+ originalEvent: eventArg,
2361
+ requestAssociation: (toSessionIdArg) => this.requestResourceAssociation(
2362
+ resourceArg.id,
2363
+ toSessionIdArg,
2364
+ sourceArg === 'keyboard' ? 'keyboard' : 'action',
2365
+ ),
2366
+ close: () => this.closeResourceContext(contextGeneration),
2367
+ };
2368
+ this.dispatchEvent(
2369
+ new CustomEvent('harness-resource-context', { detail, bubbles: true, composed: true }),
2370
+ );
2371
+ }
2372
+
2373
+ private closeResourceContext(contextGenerationArg: number): void {
2374
+ if (
2375
+ !this.isConnected
2376
+ || contextGenerationArg !== this.resourceContextGeneration
2377
+ ) return;
2378
+ const focusTarget = this.resourceContextFocusTarget;
2379
+ const resourceId = this.resourceContextResourceId;
2380
+ const focusClass = this.resourceContextFocusClass;
2381
+ this.clearResourceContextState();
2382
+ const closeGeneration = this.resourceContextGeneration;
2383
+ void this.updateComplete.then(() => {
2384
+ if (!this.isConnected || closeGeneration !== this.resourceContextGeneration) return;
2385
+ if (focusTarget?.isConnected) {
2386
+ focusTarget.focus();
2387
+ return;
2388
+ }
2389
+ const row = Array.from(
2390
+ this.shadowRoot?.querySelectorAll<HTMLElement>('.resourceRow[data-resource-id]') ?? [],
2391
+ ).find((candidate) => candidate.dataset.resourceId === resourceId);
2392
+ row?.querySelector<HTMLElement>(`.${focusClass || 'resourcePrimary'}`)?.focus();
2393
+ });
2394
+ }
2395
+
2396
+ private clearResourceContextState(): void {
2397
+ this.resourceContextGeneration += 1;
2398
+ this.resourceContextResourceId = '';
2399
+ this.resourceContextFocusTarget = undefined;
2400
+ this.resourceContextFocusClass = '';
2401
+ }
2402
+
2403
+ private reconcilePendingResourceAssociation(): void {
2404
+ const pending = this.pendingResourceAssociationRequest;
2405
+ if (!pending) return;
2406
+ if (!this.resourcesById.has(pending.resourceId)) {
2407
+ this.pendingResourceAssociationRequest = undefined;
2408
+ return;
2409
+ }
2410
+ const currentSessionId = this.acceptedResourceAssociations.get(pending.resourceId) ?? null;
2411
+ if (currentSessionId !== pending.toSessionId) {
2412
+ if (currentSessionId !== pending.fromSessionId) {
2413
+ const controlledSession = currentSessionId
2414
+ ? this.sessionsById.get(currentSessionId)?.title ?? currentSessionId
2415
+ : 'unattached';
2416
+ this.moveAnnouncement = `Association request for ${pending.resourceTitle} was not applied; controlled association is ${controlledSession}.`;
2417
+ this.pendingResourceAssociationRequest = undefined;
2418
+ }
2419
+ return;
2420
+ }
2421
+ const fromSession = pending.fromSessionId
2422
+ ? this.sessionsById.get(pending.fromSessionId)?.title ?? pending.fromSessionId
2423
+ : '';
2424
+ const toSession = pending.toSessionId
2425
+ ? this.sessionsById.get(pending.toSessionId)?.title ?? pending.toSessionId
2426
+ : '';
2427
+ this.moveAnnouncement = pending.intent === 'attach'
2428
+ ? `${pending.resourceTitle} is now attached to ${toSession}.`
2429
+ : pending.intent === 'detach'
2430
+ ? `${pending.resourceTitle} is now detached from ${fromSession}.`
2431
+ : `${pending.resourceTitle} is now reassigned from ${fromSession} to ${toSession}.`;
2432
+ this.pendingResourceAssociationRequest = undefined;
2433
+ }
2434
+
2435
+ private resourceAssociationAnnouncement(
2436
+ detailArg: THarnessResourceAssociationRequestDetail,
2437
+ completedArg: boolean,
2438
+ ): string {
2439
+ const resourceTitle = detailArg.resource.title || detailArg.resource.id;
2440
+ const fromTitle = detailArg.fromSessionId
2441
+ ? this.sessionsById.get(detailArg.fromSessionId)?.title ?? detailArg.fromSessionId
2442
+ : '';
2443
+ const toTitle = detailArg.toSessionId
2444
+ ? this.sessionsById.get(detailArg.toSessionId)?.title ?? detailArg.toSessionId
2445
+ : '';
2446
+ const verb = completedArg ? 'completed' : 'requested';
2447
+ return detailArg.intent === 'attach'
2448
+ ? `Attach ${verb} for ${resourceTitle} to ${toTitle}.`
2449
+ : detailArg.intent === 'detach'
2450
+ ? `Detach ${verb} for ${resourceTitle} from ${fromTitle}.`
2451
+ : `Reassign ${verb} for ${resourceTitle} from ${fromTitle} to ${toTitle}.`;
2452
+ }
2453
+
1588
2454
  private async animateCardReflow(
1589
2455
  previousRectsArg: Map<string, DOMRect>,
1590
2456
  generationArg: number,
@@ -1626,9 +2492,25 @@ export class DeesHarnessSessionList extends DeesElement {
1626
2492
  private finishPointerDrag(commitArg: boolean): void {
1627
2493
  const gesture = this.pointerDragGesture;
1628
2494
  if (!gesture) return;
1629
- const detail = commitArg && gesture.phase === 'active' && this.dropSlot
2495
+ const sessionMoveDetail = (
2496
+ gesture.type === 'session'
2497
+ && commitArg
2498
+ && gesture.phase === 'active'
2499
+ && this.dropSlot
2500
+ )
1630
2501
  ? this.createMoveDetail(gesture.sessionId, this.dropSlot.groupId, this.dropSlot.index)
1631
2502
  : undefined;
2503
+ const resourceRequest = (
2504
+ gesture.type === 'resource'
2505
+ && commitArg
2506
+ && gesture.phase === 'active'
2507
+ && this.resourceDropTargetSessionId
2508
+ )
2509
+ ? {
2510
+ resourceId: gesture.resourceId,
2511
+ toSessionId: this.resourceDropTargetSessionId,
2512
+ }
2513
+ : undefined;
1632
2514
  this.pointerDragGesture = undefined;
1633
2515
  window.removeEventListener('keydown', this.handleDragKeyDown, true);
1634
2516
  window.removeEventListener('blur', this.handleDragWindowBlur);
@@ -1637,14 +2519,19 @@ export class DeesHarnessSessionList extends DeesElement {
1637
2519
  this.cancelReflowAnimations();
1638
2520
  if (gesture.source.matches(':popover-open')) gesture.source.hidePopover();
1639
2521
  gesture.source.removeAttribute('popover');
1640
- gesture.source.classList.remove('dragging');
2522
+ gesture.source.classList.remove('dragging', 'resourceDragging');
1641
2523
  for (const property of [
1642
2524
  '--drag-preview-width',
1643
2525
  '--drag-preview-height',
1644
2526
  '--drag-preview-x',
1645
2527
  '--drag-preview-y',
2528
+ '--resource-drag-preview-width',
2529
+ '--resource-drag-preview-x',
2530
+ '--resource-drag-preview-y',
1646
2531
  ]) gesture.source.style.removeProperty(property);
1647
2532
  this.draggingSessionId = '';
2533
+ this.draggingResourceId = '';
2534
+ this.resourceDropTargetSessionId = '';
1648
2535
  this.draggedItemHeight = 0;
1649
2536
  this.dropSlot = undefined;
1650
2537
  try {
@@ -1654,11 +2541,18 @@ export class DeesHarnessSessionList extends DeesElement {
1654
2541
  } catch {
1655
2542
  // Capture may already be released or the handle may have disconnected.
1656
2543
  }
1657
- if (detail) {
2544
+ if (sessionMoveDetail) {
1658
2545
  this.dispatchEvent(
1659
- new CustomEvent('harness-session-move', { detail, bubbles: true, composed: true }),
2546
+ new CustomEvent('harness-session-move', {
2547
+ detail: sessionMoveDetail,
2548
+ bubbles: true,
2549
+ composed: true,
2550
+ }),
1660
2551
  );
1661
2552
  }
2553
+ if (resourceRequest) {
2554
+ this.requestResourceAssociation(resourceRequest.resourceId, resourceRequest.toSessionId, 'drop');
2555
+ }
1662
2556
  }
1663
2557
 
1664
2558
  private emit(name: string, session: IHarnessSessionMeta): void {