@design.estate/dees-catalog 6.8.2 → 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.
@@ -14,6 +14,7 @@ import type {
14
14
  IHarnessAccountChangeDetail,
15
15
  IHarnessAttachment,
16
16
  IHarnessComposerOption,
17
+ IHarnessComposerSuggestion,
17
18
  IHarnessMessage,
18
19
  IHarnessPermissionRequest,
19
20
  IHarnessQuestionRequest,
@@ -154,6 +155,9 @@ export class DeesHarnessChat extends DeesElement {
154
155
  @property({ attribute: false })
155
156
  accessor attachments: IHarnessAttachment[] = [];
156
157
 
158
+ @property({ attribute: false })
159
+ accessor suggestions: IHarnessComposerSuggestion[] = [];
160
+
157
161
  @property({ type: Number })
158
162
  accessor maxAttachmentCount: number = Number.POSITIVE_INFINITY;
159
163
 
@@ -659,6 +663,7 @@ export class DeesHarnessChat extends DeesElement {
659
663
  docked
660
664
  .value=${this.composerValue}
661
665
  .attachments=${this.attachments}
666
+ .suggestions=${this.suggestions}
662
667
  .maxAttachmentCount=${this.maxAttachmentCount}
663
668
  .maxAttachmentBytes=${this.maxAttachmentBytes}
664
669
  .maxTotalAttachmentBytes=${this.maxTotalAttachmentBytes}
@@ -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;
@@ -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
  }