@design.estate/dees-catalog 11.2.2 → 12.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist_bundle/bundle.js +1898 -1292
  2. package/dist_bundle/bundle.js.map +1 -1
  3. package/dist_ts_web/00_commitinfo_data.js +1 -1
  4. package/dist_ts_web/elements/00group-harness/dees-harness-conversation-picker/dees-harness-conversation-picker.d.ts +62 -0
  5. package/dist_ts_web/elements/00group-harness/dees-harness-conversation-picker/dees-harness-conversation-picker.demo.d.ts +1 -0
  6. package/dist_ts_web/elements/00group-harness/dees-harness-conversation-picker/dees-harness-conversation-picker.demo.js +38 -0
  7. package/dist_ts_web/elements/00group-harness/dees-harness-conversation-picker/dees-harness-conversation-picker.js +455 -0
  8. package/dist_ts_web/elements/00group-harness/dees-harness-conversation-picker/index.d.ts +1 -0
  9. package/dist_ts_web/elements/00group-harness/dees-harness-conversation-picker/index.js +2 -0
  10. package/dist_ts_web/elements/00group-harness/dees-harness-session-list/dees-harness-session-list.d.ts +39 -10
  11. package/dist_ts_web/elements/00group-harness/dees-harness-session-list/dees-harness-session-list.demo.js +81 -27
  12. package/dist_ts_web/elements/00group-harness/dees-harness-session-list/dees-harness-session-list.js +327 -115
  13. package/dist_ts_web/elements/00group-harness/dees-terminal-view/dees-terminal-view.d.ts +22 -1
  14. package/dist_ts_web/elements/00group-harness/dees-terminal-view/dees-terminal-view.js +42 -3
  15. package/dist_ts_web/elements/00group-harness/index.d.ts +1 -0
  16. package/dist_ts_web/elements/00group-harness/index.js +2 -1
  17. package/dist_ts_web/elements/00group-harness/interfaces.d.ts +35 -16
  18. package/package.json +1 -1
  19. package/readme.md +57 -23
  20. package/ts_web/00_commitinfo_data.ts +1 -1
  21. package/ts_web/elements/00group-harness/dees-harness-conversation-picker/dees-harness-conversation-picker.demo.ts +46 -0
  22. package/ts_web/elements/00group-harness/dees-harness-conversation-picker/dees-harness-conversation-picker.ts +420 -0
  23. package/ts_web/elements/00group-harness/dees-harness-conversation-picker/index.ts +1 -0
  24. package/ts_web/elements/00group-harness/dees-harness-session-list/dees-harness-session-list.demo.ts +94 -36
  25. package/ts_web/elements/00group-harness/dees-harness-session-list/dees-harness-session-list.ts +357 -120
  26. package/ts_web/elements/00group-harness/dees-terminal-view/dees-terminal-view.ts +42 -2
  27. package/ts_web/elements/00group-harness/index.ts +1 -0
  28. package/ts_web/elements/00group-harness/interfaces.ts +41 -16
@@ -27,7 +27,8 @@ import type {
27
27
  IHarnessSessionMeta,
28
28
  THarnessItemMoveSource,
29
29
  THarnessResourceAssociationInteractionSource,
30
- THarnessResourceAssociationRequestDetail,
30
+ THarnessResourceAssociateDetail,
31
+ THarnessResourceAssociateMode,
31
32
  THarnessResourceContextSource,
32
33
  } from '../interfaces.js';
33
34
  import { harnessFormatRelativeTime } from '../harness.helpers.js';
@@ -36,6 +37,12 @@ import '../../00group-utility/dees-icon/dees-icon.js';
36
37
  const { repeat } = directives;
37
38
  const dragAutoScrollMaxPixelsPerSecond = 900;
38
39
  const dragThresholdPixels = 4;
40
+ /**
41
+ * Share of a row's height, centred, that attaches instead of inserting: the centre half of the
42
+ * row attaches and the outer quarters reposition, so reordering a resource past a conversation
43
+ * never turns into an attachment.
44
+ */
45
+ const attachCentreBandRatio = 0.5;
39
46
 
40
47
  declare global {
41
48
  interface HTMLElementTagNameMap {
@@ -98,7 +105,9 @@ interface IPendingResourceAssociationRequest {
98
105
  resourceTitle: string;
99
106
  fromTarget: IHarnessSessionListItemRef | null;
100
107
  toTarget: IHarnessSessionListItemRef | null;
101
- intent: THarnessResourceAssociationRequestDetail['intent'];
108
+ mode: THarnessResourceAssociateMode;
109
+ /** The target set the request would produce, compared against the controlled set on update. */
110
+ expectedTargets: IHarnessSessionListItemRef[];
102
111
  }
103
112
 
104
113
  interface IKeyboardMoveFocus {
@@ -118,10 +127,13 @@ type TSectionRenderEntry =
118
127
  *
119
128
  * Sessions and host resources are peers in ONE ordered list: a resource row
120
129
  * can be dragged anywhere a session can, including between sessions and into
121
- * or out of groups. Attaching a resource to a session is a separate explicit
122
- * host action (`requestResourceAssociation()` /
123
- * `harness-resource-association-request`) that never moves a row; the
124
- * association only renders as a marker on the resource row.
130
+ * or out of groups. A resource can also be attached to several items at once.
131
+ * Attaching happens either through `requestResourceAssociation()` from a host
132
+ * menu, or by releasing a resource drag on the centre half of an eligible row;
133
+ * the outer quarters of that row, and every gap between rows, stay purely
134
+ * positional. Attaching never moves a row, and the association renders only as
135
+ * markers on the resource row. Every change is requested through
136
+ * `harness-resource-associate`; the host owns the controlled set.
125
137
  *
126
138
  * The component never mutates its inputs: reordering emits `harness-item-move`,
127
139
  * the "new group" button emits `harness-group-create-request`, and
@@ -170,8 +182,10 @@ export class DeesHarnessSessionList extends DeesElement {
170
182
  accessor selectedResourceId = '';
171
183
 
172
184
  /**
173
- * Serializable attach/reassign targets for host menus. A missing entry leaves
174
- * the resource unrestricted; an empty array means no eligible target.
185
+ * Serializable association targets, honoured by host menus and by the drop
186
+ * affordance alike: an ineligible row offers no attachment and commits
187
+ * nothing. A missing entry leaves the resource unrestricted; an empty array
188
+ * means no eligible target.
175
189
  */
176
190
  @property({ attribute: false })
177
191
  accessor eligibleTargetsByResourceId: Record<string, IHarnessSessionListItemRef[]> = {};
@@ -185,6 +199,13 @@ export class DeesHarnessSessionList extends DeesElement {
185
199
  @state()
186
200
  accessor dropSlot: IDropSlot | undefined = undefined;
187
201
 
202
+ /**
203
+ * The row a resource drag would attach to. Mutually exclusive with `dropSlot`: a pointer is
204
+ * either inserting between rows or attaching to one, never both.
205
+ */
206
+ @state()
207
+ accessor dropAttachTarget: IHarnessSessionListItemRef | undefined = undefined;
208
+
188
209
  @state()
189
210
  accessor draggedItemHeight = 0;
190
211
 
@@ -699,8 +720,8 @@ export class DeesHarnessSessionList extends DeesElement {
699
720
  white-space: nowrap;
700
721
  }
701
722
 
702
- /* Decorative marker for the controlled association; never interactive. */
703
- .resourceAttachment {
723
+ /* Decorative markers for the controlled associations; never interactive. */
724
+ .resourceAttachments {
704
725
  display: flex;
705
726
  min-width: 0;
706
727
  align-items: center;
@@ -711,10 +732,49 @@ export class DeesHarnessSessionList extends DeesElement {
711
732
  font-weight: 600;
712
733
  }
713
734
 
714
- .resourceAttachment dees-icon {
735
+ .resourceAttachment {
736
+ display: flex;
737
+ min-width: 0;
738
+ align-items: center;
739
+ gap: 2px;
740
+ }
741
+
742
+ /* Past two names the row keeps one line and the title carries the full list. */
743
+ .resourceAttachmentOverflow {
744
+ flex: 0 0 auto;
745
+ }
746
+
747
+ .resourceAttachments dees-icon {
715
748
  flex: 0 0 auto;
716
749
  }
717
750
 
751
+ /*
752
+ * A drop on the centre of a row attaches to it. The ring is the whole affordance: no gap
753
+ * opens, because nothing is being inserted.
754
+ */
755
+ .item.attachTarget,
756
+ .resourceRow.attachTarget {
757
+ outline: 2px solid var(--dees-color-accent-primary);
758
+ outline-offset: -1px;
759
+ background: var(--dees-color-nav-selected);
760
+ }
761
+
762
+ /* Absolutely positioned so appearing mid-drag cannot reflow the row under the pointer. */
763
+ .attachBadge {
764
+ position: absolute;
765
+ top: 50%;
766
+ right: 10px;
767
+ display: grid;
768
+ place-items: center;
769
+ width: 20px;
770
+ height: 20px;
771
+ transform: translateY(-50%);
772
+ border-radius: var(--dees-radius-full);
773
+ background: var(--dees-color-accent-primary);
774
+ color: var(--dees-color-bg-primary);
775
+ pointer-events: none;
776
+ }
777
+
718
778
  .resourceAttachmentName {
719
779
  min-width: 0;
720
780
  overflow: hidden;
@@ -957,23 +1017,32 @@ export class DeesHarnessSessionList extends DeesElement {
957
1017
  * First valid record wins; unknown records, self-references and duplicates
958
1018
  * remain untouched but are not projected. Keyed by resource id.
959
1019
  */
960
- private get acceptedResourceAssociations(): Map<string, IHarnessSessionListItemRef> {
1020
+ private get acceptedResourceAssociations(): Map<string, IHarnessSessionListItemRef[]> {
961
1021
  const resourcesById = this.resourcesById;
962
1022
  const itemsByKey = this.itemsByKey;
963
- const result = new Map<string, IHarnessSessionListItemRef>();
1023
+ const result = new Map<string, IHarnessSessionListItemRef[]>();
964
1024
  for (const association of this.resourceAssociations) {
965
- if (
966
- !association.target
967
- || !resourcesById.has(association.resourceId)
968
- || !itemsByKey.has(itemKey(association.target))
969
- || sameItem(association.target, { kind: 'resource', id: association.resourceId })
970
- || result.has(association.resourceId)
971
- ) continue;
972
- result.set(association.resourceId, itemRef(association.target));
1025
+ if (!association || !resourcesById.has(association.resourceId)) continue;
1026
+ const targets = result.get(association.resourceId) ?? [];
1027
+ for (const target of association.targets ?? []) {
1028
+ if (
1029
+ !target
1030
+ || !itemsByKey.has(itemKey(target))
1031
+ || sameItem(target, { kind: 'resource', id: association.resourceId })
1032
+ || targets.some((candidate) => sameItem(candidate, target))
1033
+ ) continue;
1034
+ targets.push(itemRef(target));
1035
+ }
1036
+ result.set(association.resourceId, targets);
973
1037
  }
974
1038
  return result;
975
1039
  }
976
1040
 
1041
+ /** The controlled attachments of one resource, in declaration order; empty when unattached. */
1042
+ private resourceTargets(resourceIdArg: string): IHarnessSessionListItemRef[] {
1043
+ return this.acceptedResourceAssociations.get(resourceIdArg) ?? [];
1044
+ }
1045
+
977
1046
  private get acceptedResources(): IHarnessResourceMeta[] {
978
1047
  return [...this.resourcesById.values()];
979
1048
  }
@@ -1128,58 +1197,91 @@ export class DeesHarnessSessionList extends DeesElement {
1128
1197
  this.shadowRoot?.querySelector<HTMLInputElement>('.searchRow input')?.focus();
1129
1198
  }
1130
1199
 
1200
+ /**
1201
+ * Requests one association change. With no explicit mode the list infers it from the controlled
1202
+ * set: an unattached resource attaches, a singly attached one moves, and a resource with several
1203
+ * attachments is refused — only the host knows which of them the user meant, so it must pass a
1204
+ * mode (and a `fromTarget` for `move` and `detach`).
1205
+ */
1131
1206
  public requestResourceAssociation(
1132
1207
  resourceIdArg: string,
1133
1208
  toTargetArg: IHarnessSessionListItemRef | null,
1134
1209
  sourceArg: THarnessResourceAssociationInteractionSource = 'action',
1210
+ modeArg?: THarnessResourceAssociateMode,
1211
+ fromTargetArg?: IHarnessSessionListItemRef | null,
1135
1212
  ): boolean {
1136
1213
  const resource = this.resourcesById.get(resourceIdArg);
1137
1214
  if (!resource) return false;
1138
- const fromTarget = this.acceptedResourceAssociations.get(resourceIdArg) ?? null;
1139
- if (sameItem(fromTarget, toTargetArg)) return false;
1215
+ const currentTargets = this.resourceTargets(resourceIdArg);
1216
+ const toTarget = toTargetArg === null || toTargetArg === undefined ? null : itemRef(toTargetArg);
1140
1217
  if (
1141
- toTargetArg !== null
1218
+ toTarget !== null
1142
1219
  && (
1143
- !this.itemsByKey.has(itemKey(toTargetArg))
1144
- || sameItem(toTargetArg, { kind: 'resource', id: resourceIdArg })
1145
- || !this.isResourceTargetEligible(resourceIdArg, toTargetArg)
1220
+ !this.itemsByKey.has(itemKey(toTarget))
1221
+ || sameItem(toTarget, { kind: 'resource', id: resourceIdArg })
1222
+ || !this.isResourceTargetEligible(resourceIdArg, toTarget)
1146
1223
  )
1147
1224
  ) return false;
1148
- if (toTargetArg === null && fromTarget === null) return false;
1149
- const toTarget = toTargetArg === null ? null : itemRef(toTargetArg);
1150
- const detail: THarnessResourceAssociationRequestDetail = fromTarget === null
1151
- ? {
1152
- intent: 'attach',
1153
- resource,
1154
- fromTarget: null,
1155
- toTarget: toTarget!,
1156
- source: sourceArg,
1157
- }
1158
- : toTarget === null
1159
- ? {
1160
- intent: 'detach',
1161
- resource,
1162
- fromTarget,
1163
- toTarget: null,
1164
- source: sourceArg,
1165
- }
1166
- : {
1167
- intent: 'reassign',
1168
- resource,
1169
- fromTarget,
1170
- toTarget,
1171
- source: sourceArg,
1172
- };
1225
+
1226
+ const mode = modeArg ?? (
1227
+ toTarget === null
1228
+ ? 'detach'
1229
+ : currentTargets.length === 0
1230
+ ? 'attach'
1231
+ : currentTargets.length === 1
1232
+ ? 'move'
1233
+ : undefined
1234
+ );
1235
+ // An ambiguous request is refused rather than guessed at.
1236
+ if (!mode) return false;
1237
+
1238
+ const explicitFrom = fromTargetArg === null || fromTargetArg === undefined
1239
+ ? undefined
1240
+ : itemRef(fromTargetArg);
1241
+ const fromTarget = explicitFrom
1242
+ ?? (currentTargets.length === 1 ? currentTargets[0]! : undefined);
1243
+
1244
+ if (mode === 'attach' || mode === 'attach-additional') {
1245
+ if (toTarget === null) return false;
1246
+ if (currentTargets.some((candidate) => sameItem(candidate, toTarget))) return false;
1247
+ if (mode === 'attach' && currentTargets.length > 0) return false;
1248
+ if (mode === 'attach-additional' && currentTargets.length === 0) return false;
1249
+ }
1250
+ if (mode === 'move' || mode === 'detach') {
1251
+ if (!fromTarget) return false;
1252
+ if (!currentTargets.some((candidate) => sameItem(candidate, fromTarget))) return false;
1253
+ if (mode === 'move') {
1254
+ if (toTarget === null || sameItem(fromTarget, toTarget)) return false;
1255
+ if (currentTargets.some((candidate) => sameItem(candidate, toTarget))) return false;
1256
+ }
1257
+ if (mode === 'detach' && toTarget !== null) return false;
1258
+ }
1259
+
1260
+ const detail = {
1261
+ mode,
1262
+ resource,
1263
+ fromTarget: mode === 'attach' || mode === 'attach-additional' ? null : fromTarget!,
1264
+ toTarget: mode === 'detach' ? null : toTarget!,
1265
+ source: sourceArg,
1266
+ } as THarnessResourceAssociateDetail;
1267
+
1268
+ const expectedTargets = mode === 'attach' || mode === 'attach-additional'
1269
+ ? [...currentTargets, toTarget!]
1270
+ : mode === 'detach'
1271
+ ? currentTargets.filter((candidate) => !sameItem(candidate, fromTarget!))
1272
+ : currentTargets.map((candidate) => (sameItem(candidate, fromTarget!) ? toTarget! : candidate));
1273
+
1173
1274
  this.pendingResourceAssociationRequest = {
1174
1275
  resourceId: resource.id,
1175
1276
  resourceTitle: resource.title || resource.id,
1176
- fromTarget,
1177
- toTarget,
1178
- intent: detail.intent,
1277
+ fromTarget: detail.fromTarget,
1278
+ toTarget: detail.toTarget,
1279
+ mode,
1280
+ expectedTargets,
1179
1281
  };
1180
1282
  this.moveAnnouncement = this.resourceAssociationAnnouncement(detail, false);
1181
1283
  this.dispatchEvent(
1182
- new CustomEvent('harness-resource-association-request', {
1284
+ new CustomEvent('harness-resource-associate', {
1183
1285
  detail,
1184
1286
  bubbles: true,
1185
1287
  composed: true,
@@ -1439,7 +1541,7 @@ export class DeesHarnessSessionList extends DeesElement {
1439
1541
  entry.item.resource,
1440
1542
  groupIdArg,
1441
1543
  entry.logicalIndex,
1442
- associations.get(entry.item.id) ?? null,
1544
+ associations.get(entry.item.id) ?? [],
1443
1545
  ),
1444
1546
  )}
1445
1547
  ${this.draggingItem && sortableArg && !hasPlaceholder && this.sortableItems(itemsArg).length === 0
@@ -1519,6 +1621,10 @@ export class DeesHarnessSessionList extends DeesElement {
1519
1621
  session.id === this.selectedSessionId ? 'selected' : ''
1520
1622
  } ${
1521
1623
  sameItem(this.draggingItem, { kind: 'session', id: session.id }) ? 'dragging' : ''
1624
+ } ${
1625
+ sameItem(this.dropAttachTarget ?? null, { kind: 'session', id: session.id })
1626
+ ? 'attachTarget'
1627
+ : ''
1522
1628
  }"
1523
1629
  data-session-id=${session.id}
1524
1630
  data-item-kind="session"
@@ -1526,6 +1632,9 @@ export class DeesHarnessSessionList extends DeesElement {
1526
1632
  data-drop-group-id=${groupId ?? ''}
1527
1633
  data-drop-index=${dropIndex}
1528
1634
  data-drop-kind=${index < 0 ? 'slot' : 'card'}
1635
+ data-attach-target=${
1636
+ sameItem(this.dropAttachTarget ?? null, { kind: 'session', id: session.id }) ? 'true' : ''
1637
+ }
1529
1638
  @dblclick=${(event: MouseEvent) => {
1530
1639
  if (this.eventTargetsExpandToggle(event) || this.eventTargetsDragHandle(event)
1531
1640
  || this.eventTargetsSessionActions(event)) return;
@@ -1546,6 +1655,9 @@ export class DeesHarnessSessionList extends DeesElement {
1546
1655
  );
1547
1656
  }}
1548
1657
  >
1658
+ ${sameItem(this.dropAttachTarget ?? null, { kind: 'session', id: session.id })
1659
+ ? html`<span class="attachBadge" aria-hidden="true"><dees-icon icon="lucide:Link" iconSize="12"></dees-icon></span>`
1660
+ : ''}
1549
1661
  <div class="itemInner">
1550
1662
  <div class="itemHeader ${draggable ? 'draggable' : ''}">
1551
1663
  ${draggable
@@ -1634,13 +1746,17 @@ export class DeesHarnessSessionList extends DeesElement {
1634
1746
  resourceArg: IHarnessResourceMeta,
1635
1747
  groupIdArg: string | null,
1636
1748
  indexArg: number,
1637
- attachedTarget: IHarnessSessionListItemRef | null,
1749
+ attachedTargets: IHarnessSessionListItemRef[],
1638
1750
  ): TemplateResult {
1639
1751
  const selected = resourceArg.id === this.selectedResourceId;
1640
1752
  const draggable = indexArg >= 0;
1641
1753
  const dragging = sameItem(this.draggingItem, { kind: 'resource', id: resourceArg.id });
1642
1754
  const contextOpen = resourceArg.id === this.resourceContextResourceId;
1643
- const attachedTitle = attachedTarget ? this.itemTitleFor(attachedTarget) : '';
1755
+ const attachedTitles = attachedTargets.map((target) => this.itemTitleFor(target));
1756
+ // Two names read at a glance; beyond that a count keeps the row one line, with every name
1757
+ // still reachable through the title.
1758
+ const visibleAttachments = attachedTargets.slice(0, 2);
1759
+ const overflowAttachments = attachedTargets.length - visibleAttachments.length;
1644
1760
  const title = resourceArg.title || resourceArg.id;
1645
1761
  const projectLabel = resourceArg.projectLabel?.trim() ?? '';
1646
1762
  const dropIndex = indexArg < 0 ? 0 : indexArg;
@@ -1648,18 +1764,28 @@ export class DeesHarnessSessionList extends DeesElement {
1648
1764
  <article
1649
1765
  class="resourceRow ${selected ? 'selected' : ''} ${draggable ? 'draggable' : ''} ${
1650
1766
  dragging ? 'dragging' : ''
1767
+ } ${
1768
+ sameItem(this.dropAttachTarget ?? null, { kind: 'resource', id: resourceArg.id })
1769
+ ? 'attachTarget'
1770
+ : ''
1651
1771
  }"
1652
1772
  data-resource-id=${resourceArg.id}
1653
1773
  data-item-kind="resource"
1654
1774
  data-item-id=${resourceArg.id}
1655
- data-resource-target=${attachedTarget ? `${attachedTarget.kind}:${attachedTarget.id}` : ''}
1775
+ data-resource-target=${attachedTargets.map((target) => `${target.kind}:${target.id}`).join(' ')}
1776
+ data-resource-target-count=${attachedTargets.length}
1656
1777
  data-drop-group-id=${groupIdArg ?? ''}
1657
1778
  data-drop-index=${dropIndex}
1658
1779
  data-drop-kind=${indexArg < 0 ? 'slot' : 'card'}
1780
+ data-attach-target=${
1781
+ sameItem(this.dropAttachTarget ?? null, { kind: 'resource', id: resourceArg.id })
1782
+ ? 'true'
1783
+ : ''
1784
+ }
1659
1785
  @dblclick=${(event: MouseEvent) => {
1660
1786
  event.stopPropagation();
1661
1787
  if (this.eventTargetsResourceControl(event) || this.eventTargetsDragHandle(event)) return;
1662
- this.emitResource('harness-resource-open', resourceArg, attachedTarget);
1788
+ this.emitResource('harness-resource-open', resourceArg, attachedTargets);
1663
1789
  }}
1664
1790
  @contextmenu=${(event: MouseEvent) => {
1665
1791
  event.preventDefault();
@@ -1670,9 +1796,12 @@ export class DeesHarnessSessionList extends DeesElement {
1670
1796
  )) as HTMLElement | undefined)
1671
1797
  ?? (event.currentTarget as HTMLElement).querySelector<HTMLElement>('.resourcePrimary')
1672
1798
  ?? event.currentTarget as HTMLElement;
1673
- this.emitResourceContext(event, resourceArg, attachedTarget, 'pointer', focusTarget);
1799
+ this.emitResourceContext(event, resourceArg, attachedTargets, 'pointer', focusTarget);
1674
1800
  }}
1675
1801
  >
1802
+ ${sameItem(this.dropAttachTarget ?? null, { kind: 'resource', id: resourceArg.id })
1803
+ ? html`<span class="attachBadge" aria-hidden="true"><dees-icon icon="lucide:Link" iconSize="12"></dees-icon></span>`
1804
+ : ''}
1676
1805
  ${draggable
1677
1806
  ? this.renderDragHandle(
1678
1807
  { kind: 'resource', id: resourceArg.id },
@@ -1685,14 +1814,14 @@ export class DeesHarnessSessionList extends DeesElement {
1685
1814
  class="resourcePrimary"
1686
1815
  type="button"
1687
1816
  aria-current=${selected ? 'true' : 'false'}
1688
- @click=${() => this.emitResource('harness-resource-select', resourceArg, attachedTarget)}
1817
+ @click=${() => this.emitResource('harness-resource-select', resourceArg, attachedTargets)}
1689
1818
  @keydown=${(event: KeyboardEvent) => {
1690
1819
  if (event.key === 'Enter') {
1691
1820
  event.preventDefault();
1692
- this.emitResource('harness-resource-open', resourceArg, attachedTarget);
1821
+ this.emitResource('harness-resource-open', resourceArg, attachedTargets);
1693
1822
  return;
1694
1823
  }
1695
- this.handleResourceContextKey(event, resourceArg, attachedTarget);
1824
+ this.handleResourceContextKey(event, resourceArg, attachedTargets);
1696
1825
  }}
1697
1826
  >
1698
1827
  <dees-icon
@@ -1712,12 +1841,26 @@ export class DeesHarnessSessionList extends DeesElement {
1712
1841
  : ''
1713
1842
  }
1714
1843
  </span>
1715
- ${attachedTarget
1844
+ ${attachedTargets.length
1716
1845
  ? html`
1717
- <span class="resourceAttachment" title=${`Attached to ${attachedTitle}`}>
1846
+ <span class="resourceAttachments" title=${`Attached to ${attachedTitles.join(', ')}`}>
1718
1847
  <dees-icon aria-hidden="true" icon="lucide:Link" iconSize="10"></dees-icon>
1719
1848
  <span class="visuallyHidden">Attached to </span>
1720
- <span class="resourceAttachmentName">${attachedTitle}</span>
1849
+ ${repeat(
1850
+ visibleAttachments,
1851
+ (target) => itemKey(target),
1852
+ (target, index) => html`
1853
+ <span class="resourceAttachment">
1854
+ <span class="resourceAttachmentName">${this.itemTitleFor(target)}</span>
1855
+ ${index < visibleAttachments.length - 1 || overflowAttachments > 0
1856
+ ? html`<span aria-hidden="true">,</span>`
1857
+ : ''}
1858
+ </span>
1859
+ `,
1860
+ )}
1861
+ ${overflowAttachments > 0
1862
+ ? html`<span class="resourceAttachmentOverflow">+${overflowAttachments}</span>`
1863
+ : ''}
1721
1864
  </span>
1722
1865
  `
1723
1866
  : ''}
@@ -1736,13 +1879,13 @@ export class DeesHarnessSessionList extends DeesElement {
1736
1879
  this.emitResourceContext(
1737
1880
  event,
1738
1881
  resourceArg,
1739
- attachedTarget,
1882
+ attachedTargets,
1740
1883
  'action',
1741
1884
  event.currentTarget as HTMLElement,
1742
1885
  );
1743
1886
  }}
1744
1887
  @keydown=${(event: KeyboardEvent) => {
1745
- this.handleResourceContextKey(event, resourceArg, attachedTarget);
1888
+ this.handleResourceContextKey(event, resourceArg, attachedTargets);
1746
1889
  }}
1747
1890
  >
1748
1891
  <dees-icon icon="lucide:Ellipsis" iconSize="15"></dees-icon>
@@ -1971,14 +2114,31 @@ export class DeesHarnessSessionList extends DeesElement {
1971
2114
 
1972
2115
  private setDropSlot(groupIdArg: string | null, indexArg: number): void {
1973
2116
  const current = this.dropSlot;
1974
- if (current && current.groupId === groupIdArg && current.index === indexArg) return;
2117
+ if (
2118
+ current
2119
+ && current.groupId === groupIdArg
2120
+ && current.index === indexArg
2121
+ && !this.dropAttachTarget
2122
+ ) return;
2123
+ this.dropAttachTarget = undefined;
1975
2124
  const previousRects = this.captureRowRects();
1976
2125
  const generation = ++this.reflowGeneration;
1977
2126
  this.dropSlot = { groupId: groupIdArg, index: indexArg };
1978
2127
  void this.animateRowReflow(previousRects, generation);
1979
2128
  }
1980
2129
 
2130
+ private setDropAttachTarget(targetArg: IHarnessSessionListItemRef): void {
2131
+ if (sameItem(this.dropAttachTarget ?? null, targetArg) && !this.dropSlot) return;
2132
+ const previousRects = this.captureRowRects();
2133
+ const generation = ++this.reflowGeneration;
2134
+ this.dropAttachTarget = itemRef(targetArg);
2135
+ // An attaching pointer closes any open insertion gap, so the list stops offering both.
2136
+ this.dropSlot = undefined;
2137
+ void this.animateRowReflow(previousRects, generation);
2138
+ }
2139
+
1981
2140
  private clearDropSlot(): void {
2141
+ this.dropAttachTarget = undefined;
1982
2142
  if (!this.dropSlot) return;
1983
2143
  const previousRects = this.captureRowRects();
1984
2144
  const generation = ++this.reflowGeneration;
@@ -2090,12 +2250,46 @@ export class DeesHarnessSessionList extends DeesElement {
2090
2250
  const baseIndex = Number(target.dataset.dropIndex);
2091
2251
  const groupId = target.dataset.dropGroupId || null;
2092
2252
  const rect = target.getBoundingClientRect();
2253
+ const attachTarget = this.attachTargetForDrop(target, pointerY, rect);
2254
+ if (attachTarget) {
2255
+ this.setDropAttachTarget(attachTarget);
2256
+ return;
2257
+ }
2093
2258
  const index = target.dataset.dropKind === 'card' && pointerY >= rect.top + rect.height / 2
2094
2259
  ? baseIndex + 1
2095
2260
  : baseIndex;
2096
2261
  this.setDropSlot(groupId, index);
2097
2262
  }
2098
2263
 
2264
+ /**
2265
+ * A resource released on the centre of an eligible row attaches to it. Only a row qualifies —
2266
+ * never an insertion slot — and only while a resource is the dragged item. A resource that is
2267
+ * already attached to several items is excluded: a drag cannot name which attachment would
2268
+ * move, so such a row offers no affordance and the drop stays purely positional. Moving one of
2269
+ * several attachments is a menu action.
2270
+ */
2271
+ private attachTargetForDrop(
2272
+ targetArg: HTMLElement,
2273
+ pointerYArg: number,
2274
+ rectArg: DOMRect,
2275
+ ): IHarnessSessionListItemRef | undefined {
2276
+ const dragging = this.draggingItem;
2277
+ if (!dragging || dragging.kind !== 'resource') return undefined;
2278
+ if (this.resourceTargets(dragging.id).length > 1) return undefined;
2279
+ if (targetArg.dataset.dropKind !== 'card') return undefined;
2280
+ const kind = targetArg.dataset.itemKind;
2281
+ const id = targetArg.dataset.itemId;
2282
+ if ((kind !== 'session' && kind !== 'resource') || !id) return undefined;
2283
+ const candidate: IHarnessSessionListItemRef = { kind, id };
2284
+ if (sameItem(candidate, dragging)) return undefined;
2285
+ if (!this.isResourceTargetEligible(dragging.id, candidate)) return undefined;
2286
+ // The ratio is the whole band, so each side of the centre gets half of it.
2287
+ const halfBand = (rectArg.height * attachCentreBandRatio) / 2;
2288
+ const centre = rectArg.top + rectArg.height / 2;
2289
+ if (Math.abs(pointerYArg - centre) > halfBand) return undefined;
2290
+ return candidate;
2291
+ }
2292
+
2099
2293
  private get scrollContainer(): HTMLElement | undefined {
2100
2294
  return this.shadowRoot?.querySelector<HTMLElement>('.list') ?? undefined;
2101
2295
  }
@@ -2282,6 +2476,69 @@ export class DeesHarnessSessionList extends DeesElement {
2282
2476
  return eligible === undefined || eligible.some((candidate) => sameItem(candidate, targetArg));
2283
2477
  }
2284
2478
 
2479
+ private reconcilePendingResourceAssociation(): void {
2480
+ const pending = this.pendingResourceAssociationRequest;
2481
+ if (!pending) return;
2482
+ if (!this.resourcesById.has(pending.resourceId)) {
2483
+ this.pendingResourceAssociationRequest = undefined;
2484
+ return;
2485
+ }
2486
+ const current = this.resourceTargets(pending.resourceId);
2487
+ const sameSet = (leftArg: IHarnessSessionListItemRef[], rightArg: IHarnessSessionListItemRef[]): boolean =>
2488
+ leftArg.length === rightArg.length
2489
+ && leftArg.every((candidate) => rightArg.some((other) => sameItem(candidate, other)));
2490
+ if (!sameSet(current, pending.expectedTargets)) {
2491
+ // Still the pre-request set: the host has not answered yet, so keep waiting.
2492
+ if (sameSet(current, this.pendingRequestPriorTargets(pending))) return;
2493
+ const controlledTitle = current.length
2494
+ ? current.map((target) => this.itemTitleFor(target)).join(', ')
2495
+ : 'unattached';
2496
+ this.moveAnnouncement = `Association request for ${pending.resourceTitle} was not applied; controlled attachments are ${controlledTitle}.`;
2497
+ this.pendingResourceAssociationRequest = undefined;
2498
+ return;
2499
+ }
2500
+ const fromTitle = pending.fromTarget ? this.itemTitleFor(pending.fromTarget) : '';
2501
+ const toTitle = pending.toTarget ? this.itemTitleFor(pending.toTarget) : '';
2502
+ this.moveAnnouncement = pending.mode === 'attach' || pending.mode === 'attach-additional'
2503
+ ? `${pending.resourceTitle} is now attached to ${toTitle}.`
2504
+ : pending.mode === 'detach'
2505
+ ? `${pending.resourceTitle} is now detached from ${fromTitle}.`
2506
+ : `${pending.resourceTitle} is now moved from ${fromTitle} to ${toTitle}.`;
2507
+ this.pendingResourceAssociationRequest = undefined;
2508
+ }
2509
+
2510
+ /** The set the request started from, reconstructed from the expected set and the request. */
2511
+ private pendingRequestPriorTargets(
2512
+ pendingArg: IPendingResourceAssociationRequest,
2513
+ ): IHarnessSessionListItemRef[] {
2514
+ if (pendingArg.mode === 'attach' || pendingArg.mode === 'attach-additional') {
2515
+ return pendingArg.expectedTargets.filter((candidate) => !sameItem(candidate, pendingArg.toTarget));
2516
+ }
2517
+ if (pendingArg.mode === 'detach') {
2518
+ return [...pendingArg.expectedTargets, pendingArg.fromTarget!];
2519
+ }
2520
+ return pendingArg.expectedTargets.map(
2521
+ (candidate) => (sameItem(candidate, pendingArg.toTarget) ? pendingArg.fromTarget! : candidate),
2522
+ );
2523
+ }
2524
+
2525
+ private resourceAssociationAnnouncement(
2526
+ detailArg: THarnessResourceAssociateDetail,
2527
+ completedArg: boolean,
2528
+ ): string {
2529
+ const resourceTitle = detailArg.resource.title || detailArg.resource.id;
2530
+ const fromTitle = detailArg.fromTarget ? this.itemTitleFor(detailArg.fromTarget) : '';
2531
+ const toTitle = detailArg.toTarget ? this.itemTitleFor(detailArg.toTarget) : '';
2532
+ const verb = completedArg ? 'completed' : 'requested';
2533
+ return detailArg.mode === 'attach'
2534
+ ? `Attach ${verb} for ${resourceTitle} to ${toTitle}.`
2535
+ : detailArg.mode === 'attach-additional'
2536
+ ? `Additional attach ${verb} for ${resourceTitle} to ${toTitle}.`
2537
+ : detailArg.mode === 'detach'
2538
+ ? `Detach ${verb} for ${resourceTitle} from ${fromTitle}.`
2539
+ : `Move ${verb} for ${resourceTitle} from ${fromTitle} to ${toTitle}.`;
2540
+ }
2541
+
2285
2542
  private itemTitleFor(refArg: IHarnessSessionListItemRef): string {
2286
2543
  return this.itemsByKey.get(itemKey(refArg))?.title ?? refArg.id;
2287
2544
  }
@@ -2294,11 +2551,11 @@ export class DeesHarnessSessionList extends DeesElement {
2294
2551
  private emitResource(
2295
2552
  nameArg: string,
2296
2553
  resourceArg: IHarnessResourceMeta,
2297
- targetArg: IHarnessSessionListItemRef | null,
2554
+ targetsArg: IHarnessSessionListItemRef[],
2298
2555
  ): void {
2299
2556
  const detail: IHarnessResourceEventDetail = {
2300
2557
  resource: resourceArg,
2301
- target: targetArg,
2558
+ targets: targetsArg,
2302
2559
  };
2303
2560
  this.dispatchEvent(new CustomEvent(nameArg, { detail, bubbles: true, composed: true }));
2304
2561
  }
@@ -2306,7 +2563,7 @@ export class DeesHarnessSessionList extends DeesElement {
2306
2563
  private handleResourceContextKey(
2307
2564
  eventArg: KeyboardEvent,
2308
2565
  resourceArg: IHarnessResourceMeta,
2309
- targetArg: IHarnessSessionListItemRef | null,
2566
+ targetsArg: IHarnessSessionListItemRef[],
2310
2567
  ): void {
2311
2568
  if (!(eventArg.key === 'ContextMenu' || (eventArg.key === 'F10' && eventArg.shiftKey))) return;
2312
2569
  eventArg.preventDefault();
@@ -2314,7 +2571,7 @@ export class DeesHarnessSessionList extends DeesElement {
2314
2571
  this.emitResourceContext(
2315
2572
  eventArg,
2316
2573
  resourceArg,
2317
- targetArg,
2574
+ targetsArg,
2318
2575
  'keyboard',
2319
2576
  eventArg.currentTarget as HTMLElement,
2320
2577
  );
@@ -2323,7 +2580,7 @@ export class DeesHarnessSessionList extends DeesElement {
2323
2580
  private emitResourceContext(
2324
2581
  eventArg: MouseEvent | KeyboardEvent,
2325
2582
  resourceArg: IHarnessResourceMeta,
2326
- targetArg: IHarnessSessionListItemRef | null,
2583
+ targetsArg: IHarnessSessionListItemRef[],
2327
2584
  sourceArg: THarnessResourceContextSource,
2328
2585
  focusTargetArg: HTMLElement,
2329
2586
  ): void {
@@ -2338,7 +2595,7 @@ export class DeesHarnessSessionList extends DeesElement {
2338
2595
  ].find((className) => focusTargetArg.classList.contains(className)) ?? 'resourcePrimary';
2339
2596
  const detail: IHarnessResourceContextDetail = {
2340
2597
  resource: resourceArg,
2341
- target: targetArg,
2598
+ targets: targetsArg,
2342
2599
  clientX: sourceArg === 'pointer' && eventArg instanceof MouseEvent
2343
2600
  ? eventArg.clientX
2344
2601
  : rect.left + rect.width / 2,
@@ -2347,10 +2604,12 @@ export class DeesHarnessSessionList extends DeesElement {
2347
2604
  : rect.bottom,
2348
2605
  source: sourceArg,
2349
2606
  originalEvent: eventArg,
2350
- requestAssociation: (toTargetArg) => this.requestResourceAssociation(
2607
+ requestAssociation: (toTargetArg, modeArg, fromTargetArg) => this.requestResourceAssociation(
2351
2608
  resourceArg.id,
2352
2609
  toTargetArg,
2353
2610
  sourceArg === 'keyboard' ? 'keyboard' : 'action',
2611
+ modeArg,
2612
+ fromTargetArg,
2354
2613
  ),
2355
2614
  close: () => this.closeResourceContext(contextGeneration),
2356
2615
  };
@@ -2387,47 +2646,6 @@ export class DeesHarnessSessionList extends DeesElement {
2387
2646
  this.resourceContextFocusClass = '';
2388
2647
  }
2389
2648
 
2390
- private reconcilePendingResourceAssociation(): void {
2391
- const pending = this.pendingResourceAssociationRequest;
2392
- if (!pending) return;
2393
- if (!this.resourcesById.has(pending.resourceId)) {
2394
- this.pendingResourceAssociationRequest = undefined;
2395
- return;
2396
- }
2397
- const currentTarget = this.acceptedResourceAssociations.get(pending.resourceId) ?? null;
2398
- if (!sameItem(currentTarget, pending.toTarget) && !(currentTarget === null && pending.toTarget === null)) {
2399
- if (!sameItem(currentTarget, pending.fromTarget) && !(currentTarget === null && pending.fromTarget === null)) {
2400
- const controlledTitle = currentTarget ? this.itemTitleFor(currentTarget) : 'unattached';
2401
- this.moveAnnouncement = `Association request for ${pending.resourceTitle} was not applied; controlled association is ${controlledTitle}.`;
2402
- this.pendingResourceAssociationRequest = undefined;
2403
- }
2404
- return;
2405
- }
2406
- const fromTitle = pending.fromTarget ? this.itemTitleFor(pending.fromTarget) : '';
2407
- const toTitle = pending.toTarget ? this.itemTitleFor(pending.toTarget) : '';
2408
- this.moveAnnouncement = pending.intent === 'attach'
2409
- ? `${pending.resourceTitle} is now attached to ${toTitle}.`
2410
- : pending.intent === 'detach'
2411
- ? `${pending.resourceTitle} is now detached from ${fromTitle}.`
2412
- : `${pending.resourceTitle} is now reassigned from ${fromTitle} to ${toTitle}.`;
2413
- this.pendingResourceAssociationRequest = undefined;
2414
- }
2415
-
2416
- private resourceAssociationAnnouncement(
2417
- detailArg: THarnessResourceAssociationRequestDetail,
2418
- completedArg: boolean,
2419
- ): string {
2420
- const resourceTitle = detailArg.resource.title || detailArg.resource.id;
2421
- const fromTitle = detailArg.fromTarget ? this.itemTitleFor(detailArg.fromTarget) : '';
2422
- const toTitle = detailArg.toTarget ? this.itemTitleFor(detailArg.toTarget) : '';
2423
- const verb = completedArg ? 'completed' : 'requested';
2424
- return detailArg.intent === 'attach'
2425
- ? `Attach ${verb} for ${resourceTitle} to ${toTitle}.`
2426
- : detailArg.intent === 'detach'
2427
- ? `Detach ${verb} for ${resourceTitle} from ${fromTitle}.`
2428
- : `Reassign ${verb} for ${resourceTitle} from ${fromTitle} to ${toTitle}.`;
2429
- }
2430
-
2431
2649
  private async animateRowReflow(
2432
2650
  previousRectsArg: Map<string, DOMRect>,
2433
2651
  generationArg: number,
@@ -2470,7 +2688,12 @@ export class DeesHarnessSessionList extends DeesElement {
2470
2688
  private finishPointerDrag(commitArg: boolean): void {
2471
2689
  const gesture = this.pointerDragGesture;
2472
2690
  if (!gesture) return;
2473
- const moveDetail = (commitArg && gesture.phase === 'active' && this.dropSlot)
2691
+ const committing = commitArg && gesture.phase === 'active';
2692
+ // A centred release attaches instead of reordering; the two are never both pending.
2693
+ const attachTarget = committing && gesture.item.kind === 'resource'
2694
+ ? this.dropAttachTarget
2695
+ : undefined;
2696
+ const moveDetail = (committing && !attachTarget && this.dropSlot)
2474
2697
  ? this.createMoveDetail(gesture.item, this.dropSlot.groupId, this.dropSlot.index, 'pointer')
2475
2698
  : undefined;
2476
2699
  this.pointerDragGesture = undefined;
@@ -2491,6 +2714,7 @@ export class DeesHarnessSessionList extends DeesElement {
2491
2714
  this.draggingItem = undefined;
2492
2715
  this.draggedItemHeight = 0;
2493
2716
  this.dropSlot = undefined;
2717
+ this.dropAttachTarget = undefined;
2494
2718
  try {
2495
2719
  if (gesture.handle.hasPointerCapture(gesture.pointerId)) {
2496
2720
  gesture.handle.releasePointerCapture(gesture.pointerId);
@@ -2498,6 +2722,19 @@ export class DeesHarnessSessionList extends DeesElement {
2498
2722
  } catch {
2499
2723
  // Capture may already be released or the handle may have disconnected.
2500
2724
  }
2725
+ if (attachTarget) {
2726
+ // Only an unattached or singly attached resource can reach this point, so the mode follows
2727
+ // from the current set without asking anything further.
2728
+ const current = this.resourceTargets(gesture.item.id);
2729
+ this.requestResourceAssociation(
2730
+ gesture.item.id,
2731
+ attachTarget,
2732
+ 'pointer',
2733
+ current.length === 0 ? 'attach' : 'move',
2734
+ current[0],
2735
+ );
2736
+ return;
2737
+ }
2501
2738
  if (moveDetail) this.emitItemMove(moveDetail);
2502
2739
  }
2503
2740
  }