@design.estate/dees-catalog 6.8.1 → 6.9.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.
@@ -6,6 +6,7 @@ import {
6
6
  customElement,
7
7
  type TemplateResult,
8
8
  property,
9
+ state,
9
10
  cssManager,
10
11
  } from '@design.estate/dees-element';
11
12
  import { themeDefaultStyles } from '../../00theme.js';
@@ -13,6 +14,7 @@ import type {
13
14
  IHarnessAttachment,
14
15
  IHarnessComposerOption,
15
16
  IHarnessComposerSendDetail,
17
+ IHarnessComposerSuggestion,
16
18
  } from '../interfaces.js';
17
19
  import {
18
20
  harnessFileToBase64,
@@ -40,6 +42,8 @@ const toDropdownOptions = (values: string[]): TDropdownOption[] =>
40
42
  const toLabeledDropdownOptions = (values: IHarnessComposerOption[]): TDropdownOption[] =>
41
43
  values.map((value) => ({ option: value.label, key: value.value }));
42
44
 
45
+ let nextSuggestionListId = 0;
46
+
43
47
  /**
44
48
  * Prompt composer: autogrow textarea, attachments (picker / paste /
45
49
  * drag-drop with per-file and total size limits), optional account, model,
@@ -47,7 +51,7 @@ const toLabeledDropdownOptions = (values: IHarnessComposerOption[]): TDropdownOp
47
51
  * additional button and Send stays available for queueing; an empty prompt
48
52
  * with queued messages offers "Steer now".
49
53
  *
50
- * Controlled component: `value` and `attachments` are owned by the host;
54
+ * Controlled component: `value`, `attachments`, and `suggestions` are owned by the host;
51
55
  * the composer emits `harness-input`, `harness-attachments-change`,
52
56
  * `harness-attachment-error`, `harness-account-change`, `harness-model-change`, `harness-send`,
53
57
  * `harness-steer`, and `harness-abort` (all bubbling + composed).
@@ -91,6 +95,21 @@ export class DeesHarnessComposer extends DeesElement {
91
95
  @property({ attribute: false })
92
96
  accessor attachments: IHarnessAttachment[] = [];
93
97
 
98
+ @property({ attribute: false })
99
+ accessor suggestions: IHarnessComposerSuggestion[] = [];
100
+
101
+ @state()
102
+ private accessor activeSuggestionIndex: number = -1;
103
+
104
+ @state()
105
+ private accessor suggestionsDismissed: boolean = false;
106
+
107
+ @state()
108
+ private accessor promptFocused: boolean = false;
109
+
110
+ private composing: boolean = false;
111
+ private readonly suggestionListId = `dees-harness-composer-suggestions-${++nextSuggestionListId}`;
112
+
94
113
  @property({ type: Number })
95
114
  accessor maxAttachmentBytes: number = harnessMaxAttachmentBytes;
96
115
 
@@ -141,6 +160,69 @@ export class DeesHarnessComposer extends DeesElement {
141
160
  container-type: inline-size;
142
161
  }
143
162
 
163
+ .composerShell {
164
+ position: relative;
165
+ min-width: 0;
166
+ }
167
+
168
+ .suggestionList {
169
+ position: absolute;
170
+ z-index: 2;
171
+ inset-inline: 0;
172
+ bottom: calc(100% + var(--dees-spacing-xs));
173
+ box-sizing: border-box;
174
+ max-height: min(240px, 35dvh);
175
+ margin: 0;
176
+ padding: var(--dees-spacing-xs);
177
+ overflow-x: hidden;
178
+ overflow-y: auto;
179
+ overscroll-behavior: contain;
180
+ list-style: none;
181
+ border: 1px solid var(--dees-color-border-subtle);
182
+ border-radius: var(--dees-radius-xl);
183
+ corner-shape: var(--dees-corner-shape);
184
+ background: var(--dees-color-bg-primary);
185
+ box-shadow: var(--dees-shadow-up-sm);
186
+ }
187
+
188
+ .suggestionOption {
189
+ display: grid;
190
+ align-content: center;
191
+ box-sizing: border-box;
192
+ min-width: 0;
193
+ min-height: 44px;
194
+ padding: var(--dees-spacing-xs) var(--dees-spacing-sm);
195
+ border-radius: var(--dees-radius-lg);
196
+ corner-shape: var(--dees-corner-shape);
197
+ color: var(--dees-color-text-primary);
198
+ cursor: pointer;
199
+ touch-action: manipulation;
200
+ }
201
+
202
+ .suggestionOption:hover,
203
+ .suggestionOption.active {
204
+ background: var(--dees-color-fill-secondary);
205
+ }
206
+
207
+ .suggestionLabel {
208
+ min-width: 0;
209
+ overflow: hidden;
210
+ font-size: var(--dees-font-control-size, 13px);
211
+ font-weight: 600;
212
+ line-height: 1.35;
213
+ text-overflow: ellipsis;
214
+ white-space: nowrap;
215
+ }
216
+
217
+ .suggestionDescription {
218
+ min-width: 0;
219
+ margin-top: 2px;
220
+ color: var(--dees-color-text-secondary);
221
+ font-size: var(--dees-font-caption1-size, 11px);
222
+ line-height: 1.35;
223
+ overflow-wrap: anywhere;
224
+ }
225
+
144
226
  .composer {
145
227
  display: grid;
146
228
  gap: var(--dees-spacing-sm);
@@ -365,18 +447,31 @@ export class DeesHarnessComposer extends DeesElement {
365
447
 
366
448
  public clear(): void {
367
449
  this.value = '';
450
+ this.resetSuggestionInteraction();
368
451
  this.emitInput('');
369
452
  this.autogrow();
370
453
  }
371
454
 
455
+ public willUpdate(changedProperties: Map<PropertyKey, unknown>): void {
456
+ super.willUpdate(changedProperties);
457
+ if (changedProperties.has('suggestions')) this.resetSuggestionInteraction();
458
+ }
459
+
460
+ public updated(changedProperties: Map<PropertyKey, unknown>): void {
461
+ super.updated(changedProperties);
462
+ this.syncTextareaSuggestionAria();
463
+ }
464
+
372
465
  public render(): TemplateResult {
373
466
  return html`
374
- <div
375
- class="composer"
376
- @dragover=${this.handleDragOver}
377
- @dragleave=${this.handleDragLeave}
378
- @drop=${this.handleDrop}
379
- >
467
+ <div class="composerShell">
468
+ ${this.suggestionsVisible ? this.renderSuggestions() : ''}
469
+ <div
470
+ class="composer"
471
+ @dragover=${this.handleDragOver}
472
+ @dragleave=${this.handleDragLeave}
473
+ @drop=${this.handleDrop}
474
+ >
380
475
  <input
381
476
  class="fileInput"
382
477
  type="file"
@@ -392,11 +487,16 @@ export class DeesHarnessComposer extends DeesElement {
392
487
  : ''}
393
488
  <textarea
394
489
  .value=${this.value}
490
+ aria-label="Message"
395
491
  placeholder=${this.placeholder}
396
492
  ?disabled=${this.disabled}
397
493
  rows="1"
398
494
  @input=${this.handleTextInput}
399
495
  @keydown=${this.handleKeydown}
496
+ @focus=${this.handlePromptFocus}
497
+ @blur=${this.handlePromptBlur}
498
+ @compositionstart=${this.handleCompositionStart}
499
+ @compositionend=${this.handleCompositionEnd}
400
500
  @paste=${this.handlePaste}
401
501
  ></textarea>
402
502
  <div class="controlsRow">
@@ -507,11 +607,42 @@ export class DeesHarnessComposer extends DeesElement {
507
607
  Send
508
608
  </dees-button>
509
609
  `}
610
+ </div>
510
611
  </div>
511
612
  </div>
512
613
  `;
513
614
  }
514
615
 
616
+ private renderSuggestions(): TemplateResult {
617
+ return html`
618
+ <ul
619
+ id=${this.suggestionListId}
620
+ class="suggestionList"
621
+ role="listbox"
622
+ aria-label="Suggestions"
623
+ @pointerdown=${this.handleSuggestionPointerDown}
624
+ >
625
+ ${this.suggestions.map((suggestion, index) => html`
626
+ <li
627
+ id=${this.suggestionOptionId(index)}
628
+ class=${`suggestionOption ${index === this.activeSuggestionIndex ? 'active' : ''}`}
629
+ role="option"
630
+ aria-selected=${String(index === this.activeSuggestionIndex)}
631
+ @pointerenter=${() => {
632
+ this.activeSuggestionIndex = index;
633
+ }}
634
+ @click=${() => void this.selectSuggestion(index)}
635
+ >
636
+ <span class="suggestionLabel">${suggestion.label}</span>
637
+ ${suggestion.description
638
+ ? html`<span class="suggestionDescription">${suggestion.description}</span>`
639
+ : ''}
640
+ </li>
641
+ `)}
642
+ </ul>
643
+ `;
644
+ }
645
+
515
646
  private renderChip(attachment: IHarnessAttachment): TemplateResult {
516
647
  const imageUrl = attachment.kind === 'image' && attachment.dataBase64
517
648
  ? `data:${attachment.mediaType};base64,${attachment.dataBase64}`
@@ -541,16 +672,42 @@ export class DeesHarnessComposer extends DeesElement {
541
672
 
542
673
  private handleTextInput = (event: Event): void => {
543
674
  this.value = (event.currentTarget as HTMLTextAreaElement).value;
675
+ this.resetSuggestionInteraction();
544
676
  this.emitInput(this.value);
545
677
  this.autogrow();
546
678
  };
547
679
 
548
680
  private handleKeydown = (event: KeyboardEvent): void => {
681
+ if (event.isComposing || this.composing) return;
549
682
  if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
550
683
  event.preventDefault();
551
684
  this.send();
552
685
  return;
553
686
  }
687
+ if (this.suggestionsVisible) {
688
+ if (event.key === 'Escape') {
689
+ event.preventDefault();
690
+ this.suggestionsDismissed = true;
691
+ return;
692
+ }
693
+ if (!event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
694
+ if (event.key === 'ArrowDown') {
695
+ event.preventDefault();
696
+ this.moveActiveSuggestion(1);
697
+ return;
698
+ }
699
+ if (event.key === 'ArrowUp') {
700
+ event.preventDefault();
701
+ this.moveActiveSuggestion(-1);
702
+ return;
703
+ }
704
+ if (event.key === 'Enter') {
705
+ event.preventDefault();
706
+ void this.selectSuggestion(this.activeSuggestionIndex);
707
+ return;
708
+ }
709
+ }
710
+ }
554
711
  // Shift+Tab cycles the agent mode without leaving the prompt
555
712
  if (event.key === 'Tab' && event.shiftKey && this.modeOptions.length) {
556
713
  event.preventDefault();
@@ -561,6 +718,111 @@ export class DeesHarnessComposer extends DeesElement {
561
718
  }
562
719
  };
563
720
 
721
+ private handleCompositionStart = (): void => {
722
+ this.composing = true;
723
+ };
724
+
725
+ private handleCompositionEnd = (): void => {
726
+ this.composing = false;
727
+ };
728
+
729
+ private handlePromptFocus = (): void => {
730
+ this.promptFocused = true;
731
+ };
732
+
733
+ private handlePromptBlur = (): void => {
734
+ this.promptFocused = false;
735
+ };
736
+
737
+ private handleSuggestionPointerDown = (event: PointerEvent): void => {
738
+ event.preventDefault();
739
+ };
740
+
741
+ private resetSuggestionInteraction(): void {
742
+ this.activeSuggestionIndex = this.suggestions.length ? 0 : -1;
743
+ this.suggestionsDismissed = false;
744
+ }
745
+
746
+ private get suggestionsVisible(): boolean {
747
+ return this.promptFocused
748
+ && !this.disabled
749
+ && !this.suggestionsDismissed
750
+ && this.suggestions.length > 0;
751
+ }
752
+
753
+ private suggestionOptionId(indexArg: number): string {
754
+ return `${this.suggestionListId}-option-${indexArg}`;
755
+ }
756
+
757
+ private syncTextareaSuggestionAria(): void {
758
+ const textarea = this.shadowRoot?.querySelector<HTMLTextAreaElement>('textarea');
759
+ if (!textarea) return;
760
+ const suggestionsAvailable = !this.disabled && this.suggestions.length > 0;
761
+ if (suggestionsAvailable) {
762
+ textarea.setAttribute('aria-autocomplete', 'list');
763
+ textarea.setAttribute('aria-haspopup', 'listbox');
764
+ } else {
765
+ textarea.removeAttribute('aria-autocomplete');
766
+ textarea.removeAttribute('aria-haspopup');
767
+ }
768
+ if (this.suggestionsVisible) {
769
+ textarea.setAttribute('aria-controls', this.suggestionListId);
770
+ } else {
771
+ textarea.removeAttribute('aria-controls');
772
+ }
773
+ if (
774
+ this.suggestionsVisible
775
+ && this.activeSuggestionIndex >= 0
776
+ && this.activeSuggestionIndex < this.suggestions.length
777
+ ) {
778
+ textarea.setAttribute(
779
+ 'aria-activedescendant',
780
+ this.suggestionOptionId(this.activeSuggestionIndex),
781
+ );
782
+ } else {
783
+ textarea.removeAttribute('aria-activedescendant');
784
+ }
785
+ }
786
+
787
+ private moveActiveSuggestion(offsetArg: -1 | 1): void {
788
+ if (!this.suggestions.length) return;
789
+ this.activeSuggestionIndex = this.activeSuggestionIndex < 0
790
+ ? (offsetArg === 1 ? 0 : this.suggestions.length - 1)
791
+ : (this.activeSuggestionIndex + offsetArg + this.suggestions.length) % this.suggestions.length;
792
+ void this.scrollActiveSuggestionIntoView();
793
+ }
794
+
795
+ private async scrollActiveSuggestionIntoView(): Promise<void> {
796
+ await this.updateComplete;
797
+ const list = this.shadowRoot?.getElementById(this.suggestionListId);
798
+ const option = this.shadowRoot?.getElementById(
799
+ this.suggestionOptionId(this.activeSuggestionIndex),
800
+ );
801
+ if (!list || !option) return;
802
+ const optionTop = option.offsetTop;
803
+ const optionBottom = optionTop + option.offsetHeight;
804
+ if (optionTop < list.scrollTop) {
805
+ list.scrollTop = optionTop;
806
+ } else if (optionBottom > list.scrollTop + list.clientHeight) {
807
+ list.scrollTop = optionBottom - list.clientHeight;
808
+ }
809
+ }
810
+
811
+ private async selectSuggestion(indexArg: number): Promise<void> {
812
+ if (this.disabled) return;
813
+ const suggestion = this.suggestions[indexArg];
814
+ if (!suggestion) return;
815
+ this.value = suggestion.value;
816
+ this.suggestionsDismissed = true;
817
+ this.emitInput(this.value);
818
+ await this.updateComplete;
819
+ const textarea = this.shadowRoot?.querySelector<HTMLTextAreaElement>('textarea');
820
+ if (!textarea) return;
821
+ textarea.focus();
822
+ textarea.setSelectionRange(this.value.length, this.value.length);
823
+ this.autogrow();
824
+ }
825
+
564
826
  private autogrow(): void {
565
827
  const textarea = this.shadowRoot?.querySelector<HTMLTextAreaElement>('textarea');
566
828
  if (!textarea) return;
@@ -189,6 +189,10 @@ export class DeesHarnessMessageList extends DeesElement {
189
189
  private lastVirtualViewportWidth = 0;
190
190
  private pendingVirtualAnchor: { key: string; offset: number } | undefined;
191
191
  private pendingFullAnchor: { key: string; offset: number } | undefined;
192
+ private activeFullAnchor: { key: string; offset: number } | undefined;
193
+ private fullAnchorRestoreGeneration = 0;
194
+ private fullAnchorRestoreFrame: number | null = null;
195
+ private fullAnchorRestoreSecondFrame: number | null = null;
192
196
  private measurementAnchor: { key: string; offset: number } | undefined;
193
197
  private pendingVirtualRepin = false;
194
198
  private virtualizationEnabled = false;
@@ -534,17 +538,23 @@ export class DeesHarnessMessageList extends DeesElement {
534
538
  const container = this.scrollContainer;
535
539
  const preserveAnchor = this.userUnpinned || !this.autoFollow;
536
540
  const anchor = container && preserveAnchor
537
- ? this.captureCurrentAnchor(container)
541
+ ? (this.activeFullAnchor ?? this.captureCurrentAnchor(container))
538
542
  : undefined;
543
+ const anchorContentBoundaryKey = anchor
544
+ ? this.lastContentKeyForTimelineItem(anchor.key)
545
+ : undefined;
546
+ this.cancelFullAnchorRestore();
539
547
  const nextItems = this.buildTimelineRenderItems();
540
548
  const retainedKeys = new Set(nextItems.map((item) => item.key));
541
- const contentKeys = [
542
- ...this.messages.map((message) => `message:${message.id}`),
543
- ...this.permissions.map((request) => `permission:${request.id}`),
544
- ...this.questions.map((request) => `question:${request.id}`),
545
- ];
549
+ const contentKeys = nextItems.flatMap((item) => this.contentKeysForTimelineItem(item));
550
+ const anchorContentBoundaryIndex = anchorContentBoundaryKey
551
+ ? contentKeys.indexOf(anchorContentBoundaryKey)
552
+ : -1;
546
553
  this.currentNewContentKeys = new Set(
547
- contentKeys.filter((key) => !this.knownContentKeys.has(key)),
554
+ contentKeys.filter((key, index) => (
555
+ !this.knownContentKeys.has(key)
556
+ && (anchorContentBoundaryIndex < 0 || index > anchorContentBoundaryIndex)
557
+ )),
548
558
  );
549
559
  this.knownContentKeys.clear();
550
560
  for (const key of contentKeys) this.knownContentKeys.add(key);
@@ -622,6 +632,18 @@ export class DeesHarnessMessageList extends DeesElement {
622
632
  }
623
633
  }
624
634
 
635
+ private contentKeysForTimelineItem(itemArg: TTimelineRenderItem): string[] {
636
+ return itemArg.kind === 'tool-group'
637
+ ? itemArg.messages.map((item) => item.key)
638
+ : [itemArg.key];
639
+ }
640
+
641
+ private lastContentKeyForTimelineItem(keyArg: string): string | undefined {
642
+ const item = this.timelineRenderItems.find((candidate) => candidate.key === keyArg);
643
+ if (!item) return undefined;
644
+ return item.kind === 'tool-group' ? item.messages.at(-1)?.key : item.key;
645
+ }
646
+
625
647
  private pruneViewState(
626
648
  stateArg: IHarnessTranscriptViewState,
627
649
  messageIdsArg: string[],
@@ -1049,12 +1071,21 @@ export class DeesHarnessMessageList extends DeesElement {
1049
1071
  if (event.composedPath().some(
1050
1072
  (candidate) => candidate instanceof DeesHarnessMessageList && candidate !== this,
1051
1073
  )) return;
1052
- if (event.type === 'wheel' && (event as WheelEvent).deltaY >= 0) return;
1074
+ if (event.type === 'wheel') {
1075
+ this.cancelFullAnchorRestore();
1076
+ if ((event as WheelEvent).deltaY >= 0) return;
1077
+ }
1053
1078
  if (
1054
1079
  event.type === 'keydown'
1055
1080
  && !['ArrowUp', 'PageUp', 'Home'].includes((event as KeyboardEvent).key)
1056
- ) return;
1081
+ ) {
1082
+ if (['ArrowDown', 'PageDown', 'End', ' '].includes((event as KeyboardEvent).key)) {
1083
+ this.cancelFullAnchorRestore();
1084
+ }
1085
+ return;
1086
+ }
1057
1087
  if (event.type === 'pointerdown' && event.target !== this.scrollContainer) return;
1088
+ this.cancelFullAnchorRestore();
1058
1089
  this.userUnpinned = true;
1059
1090
  this.measurementAnchor = undefined;
1060
1091
  };
@@ -1063,6 +1094,7 @@ export class DeesHarnessMessageList extends DeesElement {
1063
1094
  const container = this.scrollContainer;
1064
1095
  if (!container) return;
1065
1096
  if (force) {
1097
+ this.cancelFullAnchorRestore();
1066
1098
  this.userUnpinned = false;
1067
1099
  this.measurementAnchor = undefined;
1068
1100
  }
@@ -1225,7 +1257,7 @@ export class DeesHarnessMessageList extends DeesElement {
1225
1257
  } else {
1226
1258
  this.disconnectVirtualObservers();
1227
1259
  if (this.pendingFullAnchor) {
1228
- this.restoreFullAnchor(this.scrollContainer!, this.pendingFullAnchor);
1260
+ this.scheduleFullAnchorRestore(this.scrollContainer!, this.pendingFullAnchor);
1229
1261
  this.pendingFullAnchor = undefined;
1230
1262
  }
1231
1263
  }
@@ -1408,6 +1440,46 @@ export class DeesHarnessMessageList extends DeesElement {
1408
1440
  + anchorArg.offset;
1409
1441
  }
1410
1442
 
1443
+ private scheduleFullAnchorRestore(
1444
+ containerArg: HTMLElement,
1445
+ anchorArg: { key: string; offset: number },
1446
+ ): void {
1447
+ this.cancelFullAnchorRestore();
1448
+ const generation = this.fullAnchorRestoreGeneration;
1449
+ this.activeFullAnchor = anchorArg;
1450
+ const restore = (): boolean => {
1451
+ if (
1452
+ generation !== this.fullAnchorRestoreGeneration
1453
+ || !this.isConnected
1454
+ || this.scrollContainer !== containerArg
1455
+ ) return false;
1456
+ this.restoreFullAnchor(containerArg, anchorArg);
1457
+ return true;
1458
+ };
1459
+ restore();
1460
+ queueMicrotask(() => {
1461
+ if (!restore()) return;
1462
+ this.fullAnchorRestoreFrame = requestAnimationFrame(() => {
1463
+ this.fullAnchorRestoreFrame = null;
1464
+ if (!restore()) return;
1465
+ this.fullAnchorRestoreSecondFrame = requestAnimationFrame(() => {
1466
+ this.fullAnchorRestoreSecondFrame = null;
1467
+ if (!restore()) return;
1468
+ this.activeFullAnchor = undefined;
1469
+ });
1470
+ });
1471
+ });
1472
+ }
1473
+
1474
+ private cancelFullAnchorRestore(): void {
1475
+ this.fullAnchorRestoreGeneration += 1;
1476
+ if (this.fullAnchorRestoreFrame !== null) cancelAnimationFrame(this.fullAnchorRestoreFrame);
1477
+ if (this.fullAnchorRestoreSecondFrame !== null) cancelAnimationFrame(this.fullAnchorRestoreSecondFrame);
1478
+ this.fullAnchorRestoreFrame = null;
1479
+ this.fullAnchorRestoreSecondFrame = null;
1480
+ this.activeFullAnchor = undefined;
1481
+ }
1482
+
1411
1483
  private virtualLocalScrollTop(containerArg: HTMLElement): number {
1412
1484
  const stack = this.shadowRoot?.querySelector<HTMLElement>('.virtualStack');
1413
1485
  return Math.max(0, containerArg.scrollTop - (stack?.offsetTop ?? 0));
@@ -1680,6 +1752,7 @@ export class DeesHarnessMessageList extends DeesElement {
1680
1752
 
1681
1753
  public async disconnectedCallback(): Promise<void> {
1682
1754
  this.disconnectSelectionChangeListener();
1755
+ this.cancelFullAnchorRestore();
1683
1756
  if (this.followFrame !== null) cancelAnimationFrame(this.followFrame);
1684
1757
  if (this.followSecondFrame !== null) cancelAnimationFrame(this.followSecondFrame);
1685
1758
  if (this.entryFrame !== null) cancelAnimationFrame(this.entryFrame);
@@ -464,6 +464,12 @@ export interface IHarnessComposerOption {
464
464
  value: string;
465
465
  }
466
466
 
467
+ export interface IHarnessComposerSuggestion {
468
+ label: string;
469
+ value: string;
470
+ description?: string;
471
+ }
472
+
467
473
  export interface IHarnessAccountChangeDetail {
468
474
  account: string;
469
475
  }