@design.estate/dees-catalog 11.2.2 → 12.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/dist_bundle/bundle.js +1861 -1290
  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/index.d.ts +1 -0
  14. package/dist_ts_web/elements/00group-harness/index.js +2 -1
  15. package/dist_ts_web/elements/00group-harness/interfaces.d.ts +35 -16
  16. package/package.json +1 -1
  17. package/readme.md +54 -22
  18. package/ts_web/00_commitinfo_data.ts +1 -1
  19. package/ts_web/elements/00group-harness/dees-harness-conversation-picker/dees-harness-conversation-picker.demo.ts +46 -0
  20. package/ts_web/elements/00group-harness/dees-harness-conversation-picker/dees-harness-conversation-picker.ts +420 -0
  21. package/ts_web/elements/00group-harness/dees-harness-conversation-picker/index.ts +1 -0
  22. package/ts_web/elements/00group-harness/dees-harness-session-list/dees-harness-session-list.demo.ts +94 -36
  23. package/ts_web/elements/00group-harness/dees-harness-session-list/dees-harness-session-list.ts +357 -120
  24. package/ts_web/elements/00group-harness/index.ts +1 -0
  25. package/ts_web/elements/00group-harness/interfaces.ts +41 -16
@@ -0,0 +1,420 @@
1
+ import { demoFunc } from './dees-harness-conversation-picker.demo.js';
2
+ import {
3
+ DeesElement,
4
+ html,
5
+ css,
6
+ customElement,
7
+ type TemplateResult,
8
+ property,
9
+ state,
10
+ cssManager,
11
+ } from '@design.estate/dees-element';
12
+ import { themeDefaultStyles } from '../../00theme.js';
13
+ import type {
14
+ IHarnessResourceMeta,
15
+ IHarnessSessionGroup,
16
+ IHarnessSessionListItemRef,
17
+ IHarnessSessionMeta,
18
+ } from '../interfaces.js';
19
+ import '../../00group-utility/dees-icon/dees-icon.js';
20
+
21
+ declare global {
22
+ interface HTMLElementTagNameMap {
23
+ 'dees-harness-conversation-picker': DeesHarnessConversationPicker;
24
+ }
25
+ }
26
+
27
+ interface IPickerEntry {
28
+ target: IHarnessSessionListItemRef;
29
+ title: string;
30
+ /** Project label, resource kind, or whatever second line the item offers. */
31
+ detail: string;
32
+ groupName: string;
33
+ icon: string;
34
+ }
35
+
36
+ const itemKey = (refArg: IHarnessSessionListItemRef): string => `${refArg.kind}:${refArg.id}`;
37
+
38
+ /**
39
+ * Stable per-option DOM id so the input can point `aria-activedescendant` at the active row.
40
+ * Ids only have to be unique inside this shadow root, and the key already is.
41
+ */
42
+ const optionId = (refArg: IHarnessSessionListItemRef): string =>
43
+ `picker-option-${refArg.kind}-${refArg.id}`.replace(/[^a-zA-Z0-9_-]/g, '_');
44
+
45
+ const sameRef = (
46
+ leftArg: IHarnessSessionListItemRef | undefined,
47
+ rightArg: IHarnessSessionListItemRef | undefined,
48
+ ): boolean => Boolean(leftArg && rightArg && itemKey(leftArg) === itemKey(rightArg));
49
+
50
+ /** Options for the one-call static helper. */
51
+ export interface IHarnessConversationPickerOptions {
52
+ sessions?: IHarnessSessionMeta[];
53
+ groups?: IHarnessSessionGroup[];
54
+ resources?: IHarnessResourceMeta[];
55
+ /** Targets already in use; filtered out so a picker never offers a no-op. */
56
+ excludeTargets?: IHarnessSessionListItemRef[];
57
+ /** Host-declared allowed targets; an absent list means unrestricted. */
58
+ eligibleTargets?: IHarnessSessionListItemRef[];
59
+ heading?: string;
60
+ subheading?: string;
61
+ emptyText?: string;
62
+ }
63
+
64
+ /**
65
+ * Keyboard-first picker for one conversation (or one attachable resource). It is presentation
66
+ * only: it never mutates a session, a resource, or an association — it resolves one choice and
67
+ * leaves the change to the host.
68
+ *
69
+ * Typing filters on title, project label and group name. Up/Down move the active row, Enter picks
70
+ * it, Escape cancels. `harness-picker-choose` carries the chosen `IHarnessSessionListItemRef`;
71
+ * `harness-picker-cancel` carries nothing. Use the static `pick()` helper to get both as one
72
+ * promise inside a modal.
73
+ */
74
+ @customElement('dees-harness-conversation-picker')
75
+ export class DeesHarnessConversationPicker extends DeesElement {
76
+ public static demo = demoFunc;
77
+ public static demoGroups = ['Agent Chat'];
78
+
79
+ @property({ attribute: false })
80
+ accessor sessions: IHarnessSessionMeta[] = [];
81
+
82
+ @property({ attribute: false })
83
+ accessor groups: IHarnessSessionGroup[] = [];
84
+
85
+ @property({ attribute: false })
86
+ accessor resources: IHarnessResourceMeta[] = [];
87
+
88
+ @property({ attribute: false })
89
+ accessor excludeTargets: IHarnessSessionListItemRef[] = [];
90
+
91
+ /** Absent (undefined) means unrestricted; an empty array means nothing is eligible. */
92
+ @property({ attribute: false })
93
+ accessor eligibleTargets: IHarnessSessionListItemRef[] | undefined = undefined;
94
+
95
+ @property({ type: String })
96
+ accessor heading: string = 'Attach to…';
97
+
98
+ @property({ type: String })
99
+ accessor emptyText: string = 'No conversation matches.';
100
+
101
+ @state()
102
+ accessor search: string = '';
103
+
104
+ @state()
105
+ accessor activeKey: string = '';
106
+
107
+ public static styles = [
108
+ themeDefaultStyles,
109
+ cssManager.defaultStyles,
110
+ css`
111
+ :host {
112
+ display: block;
113
+ min-width: 0;
114
+ font-family: var(--dees-font-family);
115
+ }
116
+
117
+ .searchRow {
118
+ display: flex;
119
+ align-items: center;
120
+ gap: var(--dees-spacing-sm);
121
+ padding: 0 0 var(--dees-spacing-sm);
122
+ }
123
+
124
+ .searchRow dees-icon {
125
+ flex: 0 0 auto;
126
+ color: var(--dees-color-text-secondary);
127
+ }
128
+
129
+ input {
130
+ flex: 1;
131
+ min-width: 0;
132
+ height: var(--dees-control-height-md);
133
+ padding: 0 10px;
134
+ border: 1px solid var(--dees-color-border-default);
135
+ border-radius: var(--dees-radius-md);
136
+ corner-shape: var(--dees-corner-shape);
137
+ background: var(--dees-color-control-bg);
138
+ color: var(--dees-color-text-primary);
139
+ font-family: inherit;
140
+ font-size: var(--dees-font-control-size-sm, 12px);
141
+ }
142
+
143
+ input:focus-visible {
144
+ outline: 2px solid var(--dees-color-focus-ring);
145
+ outline-offset: 1px;
146
+ }
147
+
148
+ .results {
149
+ display: grid;
150
+ gap: 2px;
151
+ max-height: 320px;
152
+ overflow-y: auto;
153
+ scrollbar-width: thin;
154
+ scrollbar-color: var(--dees-color-scrollbar-thumb) transparent;
155
+ }
156
+
157
+ .entry {
158
+ display: grid;
159
+ grid-template-columns: 20px minmax(0, 1fr);
160
+ align-items: center;
161
+ gap: var(--dees-spacing-sm);
162
+ padding: 6px 8px;
163
+ border: 0;
164
+ border-radius: var(--dees-radius-md);
165
+ corner-shape: var(--dees-corner-shape);
166
+ background: transparent;
167
+ color: var(--dees-color-text-primary);
168
+ cursor: pointer;
169
+ font-family: inherit;
170
+ text-align: left;
171
+ }
172
+
173
+ .entry:hover {
174
+ background: var(--dees-color-hover);
175
+ }
176
+
177
+ .entry.active {
178
+ background: var(--dees-color-nav-selected);
179
+ }
180
+
181
+ .entryCopy {
182
+ min-width: 0;
183
+ }
184
+
185
+ .entryTitle {
186
+ display: block;
187
+ overflow: hidden;
188
+ text-overflow: ellipsis;
189
+ white-space: nowrap;
190
+ font-size: var(--dees-font-control-size-sm, 12px);
191
+ font-weight: 590;
192
+ }
193
+
194
+ .entryMeta {
195
+ display: block;
196
+ overflow: hidden;
197
+ margin-top: 1px;
198
+ color: var(--dees-color-text-secondary);
199
+ text-overflow: ellipsis;
200
+ white-space: nowrap;
201
+ font-size: var(--dees-font-caption2-size, 10px);
202
+ }
203
+
204
+ .empty {
205
+ padding: var(--dees-spacing-md) 8px;
206
+ color: var(--dees-color-text-secondary);
207
+ text-align: center;
208
+ font-size: var(--dees-font-control-size-sm, 12px);
209
+ }
210
+ `,
211
+ ];
212
+
213
+ /** Every offerable target, before the search filter. */
214
+ private get entries(): IPickerEntry[] {
215
+ const groupNameByKey = new Map<string, string>();
216
+ for (const group of this.groups) {
217
+ for (const ref of group.itemIds ?? []) {
218
+ if (ref) groupNameByKey.set(itemKey(ref), group.name);
219
+ }
220
+ }
221
+ const excluded = new Set(this.excludeTargets.filter(Boolean).map((ref) => itemKey(ref)));
222
+ const eligible = this.eligibleTargets
223
+ ? new Set(this.eligibleTargets.filter(Boolean).map((ref) => itemKey(ref)))
224
+ : undefined;
225
+ const offerable = (refArg: IHarnessSessionListItemRef): boolean =>
226
+ !excluded.has(itemKey(refArg)) && (eligible === undefined || eligible.has(itemKey(refArg)));
227
+
228
+ const result: IPickerEntry[] = [];
229
+ for (const session of this.sessions) {
230
+ const target: IHarnessSessionListItemRef = { kind: 'session', id: session.id };
231
+ if (!session.id || !offerable(target)) continue;
232
+ result.push({
233
+ target,
234
+ title: session.title || session.id,
235
+ detail: session.projectLabel?.trim() || session.harness?.name || '',
236
+ groupName: groupNameByKey.get(itemKey(target)) ?? '',
237
+ icon: session.icon || 'lucide:MessageSquare',
238
+ });
239
+ }
240
+ for (const resource of this.resources) {
241
+ const target: IHarnessSessionListItemRef = { kind: 'resource', id: resource.id };
242
+ if (!resource.id || !offerable(target)) continue;
243
+ result.push({
244
+ target,
245
+ title: resource.title || resource.id,
246
+ detail: [resource.kind, resource.projectLabel?.trim()].filter(Boolean).join(' · '),
247
+ groupName: groupNameByKey.get(itemKey(target)) ?? '',
248
+ icon: resource.icon || 'lucide:Box',
249
+ });
250
+ }
251
+ return result;
252
+ }
253
+
254
+ /** Title, project label and group name are all searchable, like the list itself. */
255
+ private get filteredEntries(): IPickerEntry[] {
256
+ const query = this.search.trim().toLowerCase();
257
+ if (!query) return this.entries;
258
+ return this.entries.filter((entry) => [entry.title, entry.detail, entry.groupName]
259
+ .filter(Boolean)
260
+ .join(' ')
261
+ .toLowerCase()
262
+ .includes(query));
263
+ }
264
+
265
+ private get activeEntry(): IPickerEntry | undefined {
266
+ const entries = this.filteredEntries;
267
+ return entries.find((entry) => itemKey(entry.target) === this.activeKey) ?? entries[0];
268
+ }
269
+
270
+ public render(): TemplateResult {
271
+ const entries = this.filteredEntries;
272
+ const active = this.activeEntry;
273
+ return html`
274
+ <div class="searchRow">
275
+ <dees-icon icon="lucide:Search" iconSize="14"></dees-icon>
276
+ <input
277
+ type="text"
278
+ role="combobox"
279
+ aria-expanded="true"
280
+ aria-controls="picker-results"
281
+ aria-activedescendant=${active ? optionId(active.target) : ''}
282
+ aria-label=${this.heading}
283
+ placeholder="Search conversations"
284
+ .value=${this.search}
285
+ @input=${(event: Event) => {
286
+ this.search = (event.target as HTMLInputElement).value;
287
+ this.activeKey = '';
288
+ }}
289
+ @keydown=${this.handleKeyDown}
290
+ />
291
+ </div>
292
+ <div class="results" id="picker-results" role="listbox">
293
+ ${entries.length
294
+ ? entries.map((entry) => html`
295
+ <button
296
+ class=${`entry ${sameRef(entry.target, active?.target) ? 'active' : ''}`}
297
+ type="button"
298
+ role="option"
299
+ id=${optionId(entry.target)}
300
+ aria-selected=${sameRef(entry.target, active?.target) ? 'true' : 'false'}
301
+ data-target-key=${itemKey(entry.target)}
302
+ @click=${() => this.choose(entry.target)}
303
+ >
304
+ <dees-icon aria-hidden="true" icon=${entry.icon} iconSize="14"></dees-icon>
305
+ <span class="entryCopy">
306
+ <span class="entryTitle">${entry.title}</span>
307
+ ${entry.detail || entry.groupName
308
+ ? html`<span class="entryMeta">${[entry.detail, entry.groupName].filter(Boolean).join(' · ')}</span>`
309
+ : ''}
310
+ </span>
311
+ </button>
312
+ `)
313
+ : html`<div class="empty">${this.emptyText}</div>`}
314
+ </div>
315
+ `;
316
+ }
317
+
318
+ public async focusSearch(): Promise<void> {
319
+ await this.updateComplete;
320
+ this.shadowRoot?.querySelector<HTMLInputElement>('input')?.focus();
321
+ }
322
+
323
+ private readonly handleKeyDown = (eventArg: KeyboardEvent): void => {
324
+ const entries = this.filteredEntries;
325
+ if (eventArg.key === 'Escape') {
326
+ eventArg.preventDefault();
327
+ eventArg.stopPropagation();
328
+ this.cancel();
329
+ return;
330
+ }
331
+ if (eventArg.key === 'Enter') {
332
+ eventArg.preventDefault();
333
+ const active = this.activeEntry;
334
+ if (active) this.choose(active.target);
335
+ return;
336
+ }
337
+ if (eventArg.key !== 'ArrowDown' && eventArg.key !== 'ArrowUp') return;
338
+ if (!entries.length) return;
339
+ eventArg.preventDefault();
340
+ const activeIndex = Math.max(
341
+ 0,
342
+ entries.findIndex((entry) => itemKey(entry.target) === itemKey(this.activeEntry!.target)),
343
+ );
344
+ const nextIndex = eventArg.key === 'ArrowDown'
345
+ ? (activeIndex + 1) % entries.length
346
+ : (activeIndex - 1 + entries.length) % entries.length;
347
+ this.activeKey = itemKey(entries[nextIndex]!.target);
348
+ void this.scrollActiveIntoView();
349
+ };
350
+
351
+ private async scrollActiveIntoView(): Promise<void> {
352
+ await this.updateComplete;
353
+ this.shadowRoot?.querySelector<HTMLElement>('.entry.active')?.scrollIntoView({ block: 'nearest' });
354
+ }
355
+
356
+ private choose(targetArg: IHarnessSessionListItemRef): void {
357
+ this.dispatchEvent(new CustomEvent('harness-picker-choose', {
358
+ detail: { target: { kind: targetArg.kind, id: targetArg.id } },
359
+ bubbles: true,
360
+ composed: true,
361
+ }));
362
+ }
363
+
364
+ private cancel(): void {
365
+ this.dispatchEvent(new CustomEvent('harness-picker-cancel', { bubbles: true, composed: true }));
366
+ }
367
+
368
+ /**
369
+ * Opens the picker in a modal and resolves with the chosen target, or null when it was
370
+ * cancelled. Focus returns to whatever had it when the picker opened.
371
+ */
372
+ public static async pick(
373
+ optionsArg: IHarnessConversationPickerOptions = {},
374
+ ): Promise<IHarnessSessionListItemRef | null> {
375
+ const { DeesModal } = await import('../../00group-overlay/dees-modal/dees-modal.js');
376
+ const picker = new DeesHarnessConversationPicker();
377
+ picker.sessions = optionsArg.sessions ?? [];
378
+ picker.groups = optionsArg.groups ?? [];
379
+ picker.resources = optionsArg.resources ?? [];
380
+ picker.excludeTargets = optionsArg.excludeTargets ?? [];
381
+ picker.eligibleTargets = optionsArg.eligibleTargets;
382
+ picker.heading = optionsArg.heading ?? 'Attach to…';
383
+ if (optionsArg.emptyText) picker.emptyText = optionsArg.emptyText;
384
+
385
+ const previousFocus = (document.activeElement instanceof HTMLElement)
386
+ ? document.activeElement
387
+ : undefined;
388
+
389
+ return new Promise<IHarnessSessionListItemRef | null>((resolve) => {
390
+ let settled = false;
391
+ const modalPromise = DeesModal.createAndShow({
392
+ heading: optionsArg.heading ?? 'Attach to…',
393
+ subheading: optionsArg.subheading,
394
+ content: html`${picker}`,
395
+ menuOptions: [],
396
+ width: 'small',
397
+ });
398
+ const finish = (resultArg: IHarnessSessionListItemRef | null): void => {
399
+ if (settled) return;
400
+ settled = true;
401
+ picker.removeEventListener('harness-picker-choose', onChoose);
402
+ picker.removeEventListener('harness-picker-cancel', onCancel);
403
+ void modalPromise.then((modal) => modal.destroy()).finally(() => {
404
+ previousFocus?.focus();
405
+ resolve(resultArg);
406
+ });
407
+ };
408
+ const onChoose = (eventArg: Event): void => {
409
+ finish((eventArg as CustomEvent<{ target: IHarnessSessionListItemRef }>).detail.target);
410
+ };
411
+ const onCancel = (): void => finish(null);
412
+ picker.addEventListener('harness-picker-choose', onChoose);
413
+ picker.addEventListener('harness-picker-cancel', onCancel);
414
+ void modalPromise.then(async (modal) => {
415
+ modal.addEventListener('modal-close', onCancel, { once: true });
416
+ await picker.focusSearch();
417
+ });
418
+ });
419
+ }
420
+ }
@@ -0,0 +1 @@
1
+ export * from './dees-harness-conversation-picker.js';
@@ -2,6 +2,7 @@ import { DeesElement, css, customElement, directives, html, state } from '@desig
2
2
  import { demoApp, demoOutput, demoStyles, showDemoResult } from '../../../demos/templates.js';
3
3
  import { themeDefaultStyles } from '../../00theme.js';
4
4
  import { harnessPillStyles } from '../harness.styles.js';
5
+ import { DeesHarnessConversationPicker } from '../dees-harness-conversation-picker/dees-harness-conversation-picker.js';
5
6
  import { DeesContextmenu } from '../../00group-overlay/dees-contextmenu/dees-contextmenu.js';
6
7
  import '../dees-harness-question-card/dees-harness-question-card.js';
7
8
  import '../dees-harness-permission-card/dees-harness-permission-card.js';
@@ -19,7 +20,7 @@ import type {
19
20
  IHarnessSessionMeta,
20
21
  IHarnessQuestionResponseDetail,
21
22
  IHarnessPermissionResponseDetail,
22
- THarnessResourceAssociationRequestDetail,
23
+ THarnessResourceAssociateDetail,
23
24
  } from '../interfaces.js';
24
25
 
25
26
  type TDemoStage = 'idle' | 'starting' | 'working' | 'feedback' | 'approval' | 'finished' | 'error' | 'paused';
@@ -120,10 +121,14 @@ const demoResources: IHarnessResourceMeta[] = [
120
121
  },
121
122
  ];
122
123
 
123
- // An association targets any other item: a session, or a resource such as a terminal.
124
+ // A resource can be attached to several items at once, and a target is any other item: a session,
125
+ // or a resource such as a terminal.
124
126
  const demoResourceAssociations: IHarnessResourceAssociation[] = [
125
- { resourceId: 'terminal:build', target: { kind: 'session', id: 'session-4' } },
126
- { resourceId: 'file:release-notes', target: { kind: 'resource', id: 'terminal:build' } },
127
+ {
128
+ resourceId: 'terminal:build',
129
+ targets: [{ kind: 'session', id: 'session-4' }, { kind: 'session', id: 'session-0' }],
130
+ },
131
+ { resourceId: 'file:release-notes', targets: [{ kind: 'resource', id: 'terminal:build' }] },
127
132
  ];
128
133
 
129
134
  const demoEligibleSessionTargets: IHarnessSessionListItemRef[] = [
@@ -235,19 +240,28 @@ const handleSessionContext = async (
235
240
  });
236
241
  };
237
242
 
243
+ const sameTarget = (
244
+ leftArg: IHarnessSessionListItemRef,
245
+ rightArg: IHarnessSessionListItemRef,
246
+ ): boolean => leftArg.kind === rightArg.kind && leftArg.id === rightArg.id;
247
+
248
+ /** Applies one validated request to the controlled set; the list never mutates it itself. */
238
249
  const handleResourceAssociation = (
239
- event: CustomEvent<THarnessResourceAssociationRequestDetail>,
250
+ event: CustomEvent<THarnessResourceAssociateDetail>,
240
251
  ): void => {
241
252
  const list = event.currentTarget as TDemoSessionList;
253
+ const detail = event.detail;
254
+ const current = list.resourceAssociations
255
+ .find((association) => association.resourceId === detail.resource.id)?.targets ?? [];
256
+ const targets = detail.mode === 'detach'
257
+ ? current.filter((target) => !sameTarget(target, detail.fromTarget))
258
+ : detail.mode === 'move'
259
+ ? current.map((target) => (sameTarget(target, detail.fromTarget) ? { ...detail.toTarget } : target))
260
+ : [...current, { ...detail.toTarget }];
242
261
  const next = list.resourceAssociations.filter(
243
- (association) => association.resourceId !== event.detail.resource.id,
262
+ (association) => association.resourceId !== detail.resource.id,
244
263
  );
245
- if (event.detail.toTarget) {
246
- next.push({
247
- resourceId: event.detail.resource.id,
248
- target: { ...event.detail.toTarget },
249
- });
250
- }
264
+ if (targets.length) next.push({ resourceId: detail.resource.id, targets });
251
265
  list.resourceAssociations = next;
252
266
  };
253
267
 
@@ -255,36 +269,80 @@ const handleResourceContext = async (
255
269
  event: CustomEvent<IHarnessResourceContextDetail>,
256
270
  ): Promise<void> => {
257
271
  const list = event.currentTarget as TDemoSessionList;
258
- const currentTarget = event.detail.target;
259
- const eligibleTargets = list.eligibleTargetsByResourceId[event.detail.resource.id] ?? [];
260
- const titleFor = (refArg: IHarnessSessionListItemRef): string => (refArg.kind === 'session'
261
- ? list.sessions.find((session) => session.id === refArg.id)?.title
262
- : list.resources.find((resource) => resource.id === refArg.id)?.title) ?? refArg.id;
272
+ const resource = event.detail.resource;
273
+ const currentTargets = event.detail.targets;
274
+ const eligibleTargets = list.eligibleTargetsByResourceId[resource.id];
275
+ const pickerOptions = {
276
+ sessions: list.sessions,
277
+ groups: list.groups,
278
+ resources: list.resources.filter((candidate) => candidate.id !== resource.id),
279
+ eligibleTargets,
280
+ };
281
+ /** Which attachment did the user mean? Only asked when there is more than one. */
282
+ const pickAttachment = async (
283
+ headingArg: string,
284
+ ): Promise<IHarnessSessionListItemRef | null> => {
285
+ if (currentTargets.length <= 1) return currentTargets[0] ?? null;
286
+ return DeesHarnessConversationPicker.pick({
287
+ sessions: list.sessions,
288
+ groups: list.groups,
289
+ resources: list.resources,
290
+ eligibleTargets: currentTargets,
291
+ heading: headingArg,
292
+ emptyText: 'No attachment to choose.',
293
+ });
294
+ };
263
295
  const menu = await DeesContextmenu.openContextMenuWithOptions(
264
296
  new MouseEvent('contextmenu', {
265
297
  clientX: event.detail.clientX,
266
298
  clientY: event.detail.clientY,
267
299
  }),
268
300
  [
269
- ...eligibleTargets
270
- .filter((target) => !currentTarget || !sameRef(target, currentTarget))
271
- .map((target) => ({
272
- name: `${currentTarget ? 'Reassign' : 'Attach'} to ${titleFor(target)}`,
273
- iconName: 'lucide:Link',
274
- action: async () => {
275
- event.detail.requestAssociation(target);
276
- event.detail.close();
277
- },
278
- })),
279
- ...(currentTarget
280
- ? [{
281
- name: 'Detach',
282
- iconName: 'lucide:Unlink',
283
- action: async () => {
284
- event.detail.requestAssociation(null);
285
- event.detail.close();
301
+ {
302
+ name: currentTargets.length ? 'Additionally attach to…' : 'Attach to…',
303
+ iconName: 'lucide:Link',
304
+ action: async () => {
305
+ event.detail.close();
306
+ const target = await DeesHarnessConversationPicker.pick({
307
+ ...pickerOptions,
308
+ excludeTargets: currentTargets,
309
+ heading: `Attach ${resource.title} to…`,
310
+ });
311
+ if (target) {
312
+ event.detail.requestAssociation(
313
+ target,
314
+ currentTargets.length ? 'attach-additional' : 'attach',
315
+ );
316
+ }
317
+ },
318
+ },
319
+ ...(currentTargets.length
320
+ ? [
321
+ {
322
+ name: 'Move to…',
323
+ iconName: 'lucide:MoveRight',
324
+ action: async () => {
325
+ event.detail.close();
326
+ const fromTarget = await pickAttachment(`Move which attachment of ${resource.title}?`);
327
+ if (!fromTarget) return;
328
+ const target = await DeesHarnessConversationPicker.pick({
329
+ ...pickerOptions,
330
+ excludeTargets: currentTargets,
331
+ heading: `Move ${resource.title} to…`,
332
+ });
333
+ if (target) event.detail.requestAssociation(target, 'move', fromTarget);
334
+ },
335
+ },
336
+ {
337
+ name: 'Detach',
338
+ iconName: 'lucide:Unlink',
339
+ action: async () => {
340
+ event.detail.close();
341
+ const fromTarget = await pickAttachment(`Detach which attachment of ${resource.title}?`);
342
+ if (fromTarget) event.detail.requestAssociation(null, 'detach', fromTarget);
343
+ },
286
344
  },
287
- }]
345
+ ]
288
346
  : []),
289
347
  ],
290
348
  );
@@ -462,7 +520,7 @@ class DeesDemoSessionWorkspace extends DeesElement {
462
520
  this.notice = `Selected ${event.detail.resource.title}. Use Actions to attach or move it.`;
463
521
  }}
464
522
  @harness-resource-context=${handleResourceContext}
465
- @harness-resource-association-request=${(event: CustomEvent<THarnessResourceAssociationRequestDetail>) => { handleResourceAssociation(event); this.syncList(); }}
523
+ @harness-resource-associate=${(event: CustomEvent<THarnessResourceAssociateDetail>) => { handleResourceAssociation(event); this.syncList(); }}
466
524
  ></dees-harness-session-list>
467
525
  <output role="status">${this.notice}</output>
468
526
  </aside>