@design.estate/dees-catalog 9.6.2 → 9.8.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 (69) hide show
  1. package/dist_bundle/bundle.js +3138 -2725
  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/demos/inputs.d.ts +1 -1
  5. package/dist_ts_web/demos/inputs.js +5 -4
  6. package/dist_ts_web/elements/00group-appui/dees-appui/dees-appui.js +6 -1
  7. package/dist_ts_web/elements/00group-form/dees-form/dees-form.d.ts +5 -1
  8. package/dist_ts_web/elements/00group-form/dees-form/dees-form.js +27 -3
  9. package/dist_ts_web/elements/00group-harness/dees-harness-chat/dees-harness-chat.d.ts +1 -0
  10. package/dist_ts_web/elements/00group-harness/dees-harness-chat/dees-harness-chat.js +11 -2
  11. package/dist_ts_web/elements/00group-harness/dees-harness-composer/dees-harness-composer.d.ts +3 -0
  12. package/dist_ts_web/elements/00group-harness/dees-harness-composer/dees-harness-composer.demo.js +4 -1
  13. package/dist_ts_web/elements/00group-harness/dees-harness-composer/dees-harness-composer.js +64 -16
  14. package/dist_ts_web/elements/00group-harness/dees-harness-message-list/dees-harness-message-list.d.ts +4 -0
  15. package/dist_ts_web/elements/00group-harness/dees-harness-message-list/dees-harness-message-list.js +52 -1
  16. package/dist_ts_web/elements/00group-harness/dees-harness-tool-card/dees-harness-tool-card.d.ts +4 -1
  17. package/dist_ts_web/elements/00group-harness/dees-harness-tool-card/dees-harness-tool-card.js +93 -20
  18. package/dist_ts_web/elements/00group-harness/dees-harness-tool-card/renderers.js +7 -21
  19. package/dist_ts_web/elements/00group-harness/harness.helpers.d.ts +20 -0
  20. package/dist_ts_web/elements/00group-harness/harness.helpers.js +58 -1
  21. package/dist_ts_web/elements/00group-harness/interfaces.d.ts +6 -0
  22. package/dist_ts_web/elements/00group-harness/toolregistry.d.ts +8 -0
  23. package/dist_ts_web/elements/00group-harness/toolregistry.js +10 -2
  24. package/dist_ts_web/elements/00group-input/dees-input-datepicker/component.d.ts +18 -3
  25. package/dist_ts_web/elements/00group-input/dees-input-datepicker/component.js +181 -144
  26. package/dist_ts_web/elements/00group-input/dees-input-datepicker/date-validation.d.ts +13 -0
  27. package/dist_ts_web/elements/00group-input/dees-input-datepicker/date-validation.js +65 -0
  28. package/dist_ts_web/elements/00group-input/dees-input-datepicker/datepicker-popup.js +44 -56
  29. package/dist_ts_web/elements/00group-input/dees-input-datepicker/styles.js +6 -1
  30. package/dist_ts_web/elements/00group-input/dees-input-datepicker/template.js +11 -5
  31. package/dist_ts_web/elements/00group-input/dees-input-text/dees-input-text.d.ts +8 -8
  32. package/dist_ts_web/elements/00group-input/dees-input-text/dees-input-text.js +6 -53
  33. package/dist_ts_web/elements/00group-input/dees-input-textarea/dees-input-textarea.d.ts +62 -0
  34. package/dist_ts_web/elements/00group-input/dees-input-textarea/dees-input-textarea.demo.d.ts +1 -0
  35. package/dist_ts_web/elements/00group-input/dees-input-textarea/dees-input-textarea.demo.js +3 -0
  36. package/dist_ts_web/elements/00group-input/dees-input-textarea/dees-input-textarea.js +224 -0
  37. package/dist_ts_web/elements/00group-input/dees-input-textarea/index.d.ts +1 -0
  38. package/dist_ts_web/elements/00group-input/dees-input-textarea/index.js +2 -0
  39. package/dist_ts_web/elements/00group-input/index.d.ts +1 -0
  40. package/dist_ts_web/elements/00group-input/index.js +2 -1
  41. package/dist_ts_web/elements/00group-input/text-input-contextmenu.d.ts +29 -0
  42. package/dist_ts_web/elements/00group-input/text-input-contextmenu.js +29 -0
  43. package/package.json +2 -2
  44. package/readme.md +61 -6
  45. package/scripts/check-packed-consumer.mjs +8 -5
  46. package/ts_web/00_commitinfo_data.ts +1 -1
  47. package/ts_web/demos/inputs.ts +5 -4
  48. package/ts_web/elements/00group-appui/dees-appui/dees-appui.ts +4 -0
  49. package/ts_web/elements/00group-form/dees-form/dees-form.ts +25 -2
  50. package/ts_web/elements/00group-harness/dees-harness-chat/dees-harness-chat.ts +4 -0
  51. package/ts_web/elements/00group-harness/dees-harness-composer/dees-harness-composer.demo.ts +3 -0
  52. package/ts_web/elements/00group-harness/dees-harness-composer/dees-harness-composer.ts +49 -14
  53. package/ts_web/elements/00group-harness/dees-harness-message-list/dees-harness-message-list.ts +50 -0
  54. package/ts_web/elements/00group-harness/dees-harness-tool-card/dees-harness-tool-card.ts +95 -23
  55. package/ts_web/elements/00group-harness/dees-harness-tool-card/renderers.ts +7 -20
  56. package/ts_web/elements/00group-harness/harness.helpers.ts +63 -0
  57. package/ts_web/elements/00group-harness/interfaces.ts +6 -0
  58. package/ts_web/elements/00group-harness/toolregistry.ts +22 -1
  59. package/ts_web/elements/00group-input/dees-input-datepicker/component.ts +149 -148
  60. package/ts_web/elements/00group-input/dees-input-datepicker/date-validation.ts +64 -0
  61. package/ts_web/elements/00group-input/dees-input-datepicker/datepicker-popup.ts +40 -40
  62. package/ts_web/elements/00group-input/dees-input-datepicker/styles.ts +5 -0
  63. package/ts_web/elements/00group-input/dees-input-datepicker/template.ts +10 -4
  64. package/ts_web/elements/00group-input/dees-input-text/dees-input-text.ts +5 -52
  65. package/ts_web/elements/00group-input/dees-input-textarea/dees-input-textarea.demo.ts +2 -0
  66. package/ts_web/elements/00group-input/dees-input-textarea/dees-input-textarea.ts +128 -0
  67. package/ts_web/elements/00group-input/dees-input-textarea/index.ts +1 -0
  68. package/ts_web/elements/00group-input/index.ts +1 -0
  69. package/ts_web/elements/00group-input/text-input-contextmenu.ts +31 -0
@@ -13,6 +13,7 @@ import { DeesInputCheckbox } from '../../00group-input/dees-input-checkbox/dees-
13
13
  import { DeesInputCode } from '../../00group-input/dees-input-code/dees-input-code.js';
14
14
  import { DeesInputDatepicker } from '../../00group-input/dees-input-datepicker/index.js';
15
15
  import { DeesInputText } from '../../00group-input/dees-input-text/dees-input-text.js';
16
+ import { DeesInputTextarea } from '../../00group-input/dees-input-textarea/dees-input-textarea.js';
16
17
  import { DeesInputQuantitySelector } from '../../00group-input/dees-input-quantityselector/dees-input-quantityselector.js';
17
18
  import { DeesInputRadiogroup } from '../../00group-input/dees-input-radiogroup/dees-input-radiogroup.js';
18
19
  import { DeesInputDropdown } from '../../00group-input/dees-input-dropdown/dees-input-dropdown.js';
@@ -44,6 +45,7 @@ const FORM_INPUT_TYPES = [
44
45
  DeesInputQuantitySelector,
45
46
  DeesInputRadiogroup,
46
47
  DeesInputText,
48
+ DeesInputTextarea,
47
49
  DeesInputToggle,
48
50
  DeesInputTypelist,
49
51
  DeesInputTags,
@@ -65,6 +67,9 @@ export interface IFormCompatibleElement extends HTMLElement {
65
67
  };
66
68
  getValue?: () => unknown;
67
69
  setValue?: (value: unknown) => void;
70
+ checkValidity?: () => boolean;
71
+ reportValidity?: () => boolean;
72
+ reset?: () => void;
68
73
  }
69
74
 
70
75
  export type TFormDataValue =
@@ -96,6 +101,7 @@ export type TFormInputElement =
96
101
  | DeesInputQuantitySelector
97
102
  | DeesInputRadiogroup
98
103
  | DeesInputText
104
+ | DeesInputTextarea
99
105
  | DeesInputToggle
100
106
  | DeesInputTypelist
101
107
  | DeesInputTags
@@ -281,7 +287,9 @@ export class DeesForm extends DeesElement {
281
287
  public async updateRequiredStatus() {
282
288
  let requiredOK = true;
283
289
  for (const childArg of this.getFormElements()) {
284
- if (childArg.required && !getFormElementValue(childArg)) {
290
+ if (childArg.disabled) continue;
291
+ if ((childArg.required && !getFormElementValue(childArg))
292
+ || (childArg as IFormCompatibleElement).checkValidity?.() === false) {
285
293
  requiredOK = false;
286
294
  }
287
295
  }
@@ -312,6 +320,15 @@ export class DeesForm extends DeesElement {
312
320
  }
313
321
 
314
322
  public async gatherAndDispatch() {
323
+ for (const child of this.getFormElements()) {
324
+ if (child.disabled) continue;
325
+ const input = child as IFormCompatibleElement;
326
+ if (input.checkValidity?.() === false) {
327
+ input.reportValidity?.();
328
+ child.focus();
329
+ return;
330
+ }
331
+ }
315
332
  const valueObject = await this.collectFormData();
316
333
  const formDataEvent = new CustomEvent('formData', {
317
334
  detail: {
@@ -371,12 +388,18 @@ export class DeesForm extends DeesElement {
371
388
  const inputChildren = this.getFormElements();
372
389
 
373
390
  for (const inputChild of inputChildren) {
374
- setFormElementValue(inputChild, null);
391
+ const input = inputChild as IFormCompatibleElement;
392
+ if (input.reset) input.reset();
393
+ else setFormElementValue(inputChild, null);
375
394
  }
376
395
  this.setStatus('normal', 'Submit');
396
+ void this.updateRequiredStatus();
377
397
  }
378
398
 
379
399
  public async addBehaviours() {
400
+ this.addEventListener('validation-change', event => {
401
+ if (this.getFormElements().includes(event.target as TFormInputElement)) void this.updateRequiredStatus();
402
+ });
380
403
  // Use event delegation
381
404
  this.addEventListener('keydown', (event: KeyboardEvent) => {
382
405
  const target = event.target as DeesElement;
@@ -203,6 +203,9 @@ export class DeesHarnessChat extends DeesElement {
203
203
  @property({ attribute: false })
204
204
  accessor attachments: IHarnessAttachment[] = [];
205
205
 
206
+ @property({ attribute: false })
207
+ accessor attachmentsEnabled: boolean = true;
208
+
206
209
  @property({ attribute: false })
207
210
  accessor suggestions: IHarnessComposerSuggestion[] = [];
208
211
 
@@ -763,6 +766,7 @@ export class DeesHarnessChat extends DeesElement {
763
766
  .contextDescription=${this.heading ? `Replying to ${this.heading}` : ''}
764
767
  .value=${this.composerValue}
765
768
  .attachments=${this.attachments}
769
+ .attachmentsEnabled=${this.attachmentsEnabled}
766
770
  .suggestions=${this.suggestions}
767
771
  .maxAttachmentCount=${this.maxAttachmentCount}
768
772
  .maxAttachmentBytes=${this.maxAttachmentBytes}
@@ -44,6 +44,9 @@ export const demoFunc = () => demoPage('Composer', 'Write, attach files, choose
44
44
  <dees-harness-composer busy queuedCount="2"></dees-harness-composer>
45
45
  <div class="dees-demo-label">Waiting for a connection</div>
46
46
  <dees-harness-composer disabled placeholder="Connect to a project to chat."></dees-harness-composer>
47
+ <div class="dees-demo-label">Text-only conversation</div>
48
+ <dees-harness-composer .attachmentsEnabled=${false} .showModelControls=${false}
49
+ value="Summarize the outstanding invoices."></dees-harness-composer>
47
50
  <div class="dees-demo-label">Files ready to send</div>
48
51
  <dees-harness-composer
49
52
  .showModelControls=${false}
@@ -112,6 +112,10 @@ export class DeesHarnessComposer extends DeesElement {
112
112
  @property({ attribute: false })
113
113
  accessor attachments: IHarnessAttachment[] = [];
114
114
 
115
+ /** Host capability: false disables every attachment entry point and sends text only. */
116
+ @property({ attribute: false })
117
+ accessor attachmentsEnabled: boolean = true;
118
+
115
119
  @property({ attribute: false })
116
120
  accessor suggestions: IHarnessComposerSuggestion[] = [];
117
121
 
@@ -260,7 +264,12 @@ export class DeesHarnessComposer extends DeesElement {
260
264
  border: 0;
261
265
  border-top: 1px solid var(--dees-color-border-subtle);
262
266
  border-radius: 0;
263
- padding: 10px 16px 12px;
267
+ /*
268
+ * The docked bar stays full-bleed — its background and top border belong to the whole
269
+ * column — while its content is inset to the transcript's measure, so the prompt lines up
270
+ * with the messages above it instead of floating as a narrower block.
271
+ */
272
+ padding: 10px max(16px, calc((100% - var(--dees-harness-content-width, 820px)) / 2)) 12px;
264
273
  }
265
274
 
266
275
  :host([docked]) .composer.dragOver {
@@ -463,7 +472,7 @@ export class DeesHarnessComposer extends DeesElement {
463
472
  this.value = '';
464
473
  this.resetSuggestionInteraction();
465
474
  this.emitInput('');
466
- this.autogrow();
475
+ // Height is resized in updated(); measuring here would read the textarea before it rerenders.
467
476
  }
468
477
 
469
478
  public willUpdate(changedProperties: Map<PropertyKey, unknown>): void {
@@ -473,6 +482,14 @@ export class DeesHarnessComposer extends DeesElement {
473
482
 
474
483
  public updated(changedProperties: Map<PropertyKey, unknown>): void {
475
484
  super.updated(changedProperties);
485
+ if (['attachmentsEnabled', 'disabled'].some(key => changedProperties.has(key) && changedProperties.get(key) !== undefined)) {
486
+ this.attachmentGeneration++;
487
+ if (!this.attachmentsEnabled || this.disabled) this.handleDragLeave();
488
+ }
489
+ // The grown height is an inline style, so it has to be recomputed for every value change
490
+ // regardless of origin. A host that clears its bound value after send would otherwise leave
491
+ // the composer stuck at the height of the message it just sent.
492
+ if (changedProperties.has('value')) this.autogrow();
476
493
  this.syncTextareaSuggestionAria();
477
494
  }
478
495
 
@@ -486,13 +503,13 @@ export class DeesHarnessComposer extends DeesElement {
486
503
  @dragleave=${this.handleDragLeave}
487
504
  @drop=${this.handleDrop}
488
505
  >
489
- <input
506
+ ${this.attachmentsEnabled ? html`<input
490
507
  class="fileInput"
491
508
  type="file"
492
509
  multiple
493
510
  @change=${this.handleFileInputChange}
494
- />
495
- ${this.attachments.length
511
+ />` : ''}
512
+ ${this.attachmentsEnabled && this.attachments.length
496
513
  ? html`
497
514
  <div class="attachmentRow">
498
515
  ${this.attachments.map((attachment) => this.renderChip(attachment))}
@@ -505,7 +522,7 @@ export class DeesHarnessComposer extends DeesElement {
505
522
  .value=${this.value}
506
523
  aria-label="Message"
507
524
  aria-description=${this.contextDescription}
508
- placeholder=${this.placeholder}
525
+ placeholder=${!this.attachmentsEnabled && this.placeholder === 'Message the agent — drop or paste files to attach…' ? 'Message the agent…' : this.placeholder}
509
526
  ?disabled=${this.disabled}
510
527
  rows="1"
511
528
  @input=${this.handleTextInput}
@@ -518,14 +535,16 @@ export class DeesHarnessComposer extends DeesElement {
518
535
  ></textarea>
519
536
  </div>
520
537
  <div class="controlsRow">
521
- <dees-button
538
+ ${this.attachmentsEnabled ? html`<dees-button
539
+ class="attachButton"
522
540
  size="sm"
523
541
  type="ghost"
542
+ title="Attach files"
524
543
  ?disabled=${this.disabled}
525
544
  @clicked=${() => this.shadowRoot?.querySelector<HTMLInputElement>('.fileInput')?.click()}
526
545
  >
527
546
  <dees-icon icon="lucide:Paperclip" iconSize="14"></dees-icon>
528
- </dees-button>
547
+ </dees-button>` : ''}
529
548
  ${this.modeOptions.length
530
549
  ? html`
531
550
  <dees-input-multitoggle class="modeSwitch" label="Agent mode" .labelPosition=${'none'}
@@ -704,7 +723,7 @@ export class DeesHarnessComposer extends DeesElement {
704
723
  this.value = (event.currentTarget as HTMLTextAreaElement).value;
705
724
  this.resetSuggestionInteraction();
706
725
  this.emitInput(this.value);
707
- this.autogrow();
726
+ // updated() grows the textarea for every value change, so typing needs no separate call.
708
727
  };
709
728
 
710
729
  private handleKeydown = (event: KeyboardEvent): void => {
@@ -869,6 +888,7 @@ export class DeesHarnessComposer extends DeesElement {
869
888
  };
870
889
 
871
890
  private handlePaste = (event: ClipboardEvent): void => {
891
+ if (!this.attachmentsEnabled || this.disabled) return;
872
892
  const files = Array.from(event.clipboardData?.files ?? []);
873
893
  if (!files.length) return;
874
894
  event.preventDefault();
@@ -878,6 +898,10 @@ export class DeesHarnessComposer extends DeesElement {
878
898
  private handleDragOver = (event: DragEvent): void => {
879
899
  if (!event.dataTransfer?.types.includes('Files')) return;
880
900
  event.preventDefault();
901
+ if (!this.attachmentsEnabled || this.disabled) {
902
+ event.dataTransfer.dropEffect = 'none';
903
+ return;
904
+ }
881
905
  this.shadowRoot?.querySelector('.composer')?.classList.add('dragOver');
882
906
  };
883
907
 
@@ -890,23 +914,32 @@ export class DeesHarnessComposer extends DeesElement {
890
914
  const files = Array.from(event.dataTransfer?.files ?? []);
891
915
  if (!files.length) return;
892
916
  event.preventDefault();
917
+ if (!this.attachmentsEnabled || this.disabled) {
918
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'none';
919
+ return;
920
+ }
893
921
  void this.addFiles(files);
894
922
  };
895
923
 
896
924
  private attachmentAdditionTail = Promise.resolve();
925
+ private attachmentGeneration = 0;
897
926
 
898
927
  public addFiles(fileList: FileList | File[] | null): Promise<void> {
899
928
  const files = Array.from(fileList ?? []);
900
- if (!files.length) return Promise.resolve();
901
- const addition = this.attachmentAdditionTail.then(() => this.addFilesSequential(files));
929
+ if (!files.length || !this.attachmentsEnabled || this.disabled) return Promise.resolve();
930
+ const generation = this.attachmentGeneration;
931
+ const addition = this.attachmentAdditionTail.then(() => this.addFilesSequential(files, generation));
902
932
  this.attachmentAdditionTail = addition.catch(() => undefined);
903
933
  return addition;
904
934
  }
905
935
 
906
- private async addFilesSequential(files: File[]): Promise<void> {
936
+ private async addFilesSequential(files: File[], generation: number): Promise<void> {
937
+ const allowed = () => this.attachmentsEnabled && !this.disabled && generation === this.attachmentGeneration;
938
+ if (!allowed()) return;
907
939
  const added: IHarnessAttachment[] = [];
908
940
  let totalBytes = this.attachments.reduce((sum, attachment) => sum + attachment.size, 0);
909
941
  for (const file of files) {
942
+ if (!allowed()) return;
910
943
  if (this.attachments.length + added.length >= this.maxAttachmentCount) {
911
944
  this.emit('harness-attachment-error', {
912
945
  message: `A maximum of ${this.maxAttachmentCount} attachments is allowed.`,
@@ -928,6 +961,7 @@ export class DeesHarnessComposer extends DeesElement {
928
961
  }
929
962
  try {
930
963
  const dataBase64 = await harnessFileToBase64(file);
964
+ if (!allowed()) return;
931
965
  const kind = harnessAttachmentKindFor(mediaType);
932
966
  added.push({
933
967
  id: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`,
@@ -939,12 +973,13 @@ export class DeesHarnessComposer extends DeesElement {
939
973
  });
940
974
  totalBytes += file.size;
941
975
  } catch (error) {
976
+ if (!allowed()) return;
942
977
  this.emit('harness-attachment-error', {
943
978
  message: error instanceof Error ? error.message : String(error),
944
979
  });
945
980
  }
946
981
  }
947
- if (added.length) {
982
+ if (added.length && allowed()) {
948
983
  this.emitAttachments([...this.attachments, ...added]);
949
984
  }
950
985
  }
@@ -957,7 +992,7 @@ export class DeesHarnessComposer extends DeesElement {
957
992
  && (!this.busy || !this.inputSteeringEnabled || !this.value.trim())) return;
958
993
  const detail: IHarnessComposerSendDetail = {
959
994
  text: this.value,
960
- attachments: this.attachments,
995
+ attachments: this.attachmentsEnabled ? this.attachments : [],
961
996
  account: this.account || undefined,
962
997
  model: this.model || undefined,
963
998
  reasoningEffort: this.reasoningEffort || undefined,
@@ -325,8 +325,42 @@ export class DeesHarnessMessageList extends DeesElement {
325
325
  transform: translate3d(0, var(--harness-virtual-offset), 0);
326
326
  }
327
327
 
328
+ /*
329
+ * Prose is held to a readable measure while wide payloads keep the whole pane. Applied to
330
+ * the transcript rows themselves because a message host clips on both axes, so nothing
331
+ * nested inside one can bleed back out.
332
+ */
333
+ .stack > *,
334
+ .virtualItem {
335
+ width: min(var(--dees-harness-content-width, 820px), 100%);
336
+ margin-inline: auto;
337
+ }
338
+
339
+ .stack > [data-tool-layout='full-row'],
340
+ .stack > [data-tool-layout='subtask'],
341
+ .virtualItem[data-tool-layout='full-row'],
342
+ .virtualItem[data-tool-layout='subtask'] {
343
+ width: 100%;
344
+ }
345
+
346
+ /*
347
+ * One or two subagents are read like any other content, so they stay on the measure. The
348
+ * pane is only worth taking once three or more cards have to sit side by side.
349
+ */
350
+ .stack > [data-tool-layout='subtask'][data-tool-count='1'],
351
+ .stack > [data-tool-layout='subtask'][data-tool-count='2'],
352
+ .virtualItem[data-tool-layout='subtask'][data-tool-count='1'],
353
+ .virtualItem[data-tool-layout='subtask'][data-tool-count='2'] {
354
+ width: min(var(--dees-harness-content-width, 820px), 100%);
355
+ }
356
+
328
357
  .toolGroup {
329
358
  min-width: 0;
359
+ /*
360
+ * Column packing has to measure the row it happens in, not the scroller, now that a
361
+ * compact row is narrower than the pane.
362
+ */
363
+ container: harness-transcript / inline-size;
330
364
  }
331
365
 
332
366
  .toolGrid {
@@ -882,6 +916,8 @@ export class DeesHarnessMessageList extends DeesElement {
882
916
  <article
883
917
  class="virtualItem"
884
918
  data-virtual-key=${entry.item.key}
919
+ data-tool-layout=${this.timelineItemLayout(entry.item)}
920
+ data-tool-count=${this.timelineItemCount(entry.item)}
885
921
  aria-posinset=${entry.index + 1}
886
922
  aria-setsize=${this.timelineRenderItems.length}
887
923
  style=${`--harness-virtual-offset: ${this.virtualOffsets[entry.index] ?? 0}px;`}
@@ -906,6 +942,19 @@ export class DeesHarnessMessageList extends DeesElement {
906
942
  return indexes.sort((left, right) => left - right);
907
943
  }
908
944
 
945
+ /** Mirrors the row's layout onto the virtual wrapper so width rules match the flowed stack. */
946
+ private timelineItemLayout(itemArg: TTimelineRenderItem): string {
947
+ if (itemArg.kind === 'tool-group') return itemArg.layout;
948
+ if (itemArg.kind === 'message') return itemArg.toolLayout ?? 'message';
949
+ return itemArg.kind;
950
+ }
951
+
952
+ /** Group size, mirrored onto the virtual wrapper so width rules match the flowed stack. */
953
+ private timelineItemCount(itemArg: TTimelineRenderItem): number {
954
+ if (itemArg.kind !== 'tool-group') return 1;
955
+ return itemArg.messages.length >= 4 ? 4 : itemArg.messages.length;
956
+ }
957
+
909
958
  private renderTimelineItem(itemArg: TTimelineRenderItem, logicalIndexArg: number): TemplateResult {
910
959
  if (itemArg.kind === 'tool-group') return this.renderToolGroup(itemArg);
911
960
  if (itemArg.kind === 'message') return this.renderMessage(itemArg, itemArg.key);
@@ -978,6 +1027,7 @@ export class DeesHarnessMessageList extends DeesElement {
978
1027
  data-timeline-key=${groupArg.key}
979
1028
  data-tool-group-id=${groupArg.key}
980
1029
  data-tool-layout=${groupArg.layout}
1030
+ data-tool-count=${count >= 4 ? 4 : count}
981
1031
  >
982
1032
  <div class=${`toolGrid ${countClass}`}>
983
1033
  ${repeat(
@@ -111,7 +111,7 @@ export class DeesHarnessToolCard extends DeesElement {
111
111
  private fullscreenPortal: HTMLElementTagNameMap['dees-harness-tool-fullscreen'] | null = null;
112
112
  private disclosureFrame: number | null = null;
113
113
  private returnScrollFrame: number | null = null;
114
- private subtaskResultElement: HTMLElement | null = null;
114
+ private subtaskDetailElements: HTMLElement[] = [];
115
115
  private subtaskResultResizeObserver: ResizeObserver | null = null;
116
116
  private terminalTailFollowing = true;
117
117
  private terminalScrollHydrated = false;
@@ -141,7 +141,11 @@ export class DeesHarnessToolCard extends DeesElement {
141
141
  .harness-card.subtaskPreviewOpen {
142
142
  display: flex;
143
143
  flex-direction: column;
144
- height: var(--dees-harness-active-subtask-height, 320px);
144
+ /*
145
+ * A cap, not a fixed height: a short brief and result let the card size to their own
146
+ * text, and only content taller than the cap hands the overflow to the inner scrollers.
147
+ */
148
+ max-height: var(--dees-harness-active-subtask-height, 320px);
145
149
  box-sizing: border-box;
146
150
  overflow: hidden;
147
151
  }
@@ -169,7 +173,27 @@ export class DeesHarnessToolCard extends DeesElement {
169
173
  overflow: hidden;
170
174
  }
171
175
 
172
- .harness-card.subtaskPreviewOpen .body,
176
+ /*
177
+ * The body takes its height from flex layout rather than a percentage: a capped card has no
178
+ * definite height for a percentage to resolve against, so a 100% height would collapse to
179
+ * auto and the inner scrollers would never engage. As a flex item it shrinks to the cap when
180
+ * the content overflows and sizes to its text when it does not.
181
+ */
182
+ .harness-card.subtaskPreviewOpen .bodyClipFrame {
183
+ display: flex;
184
+ flex-direction: column;
185
+ }
186
+
187
+ .harness-card.subtaskPreviewOpen .body {
188
+ flex: 1;
189
+ min-height: 0;
190
+ }
191
+
192
+ /* A streaming child fills the cap so its transcript has a stable window to scroll in. */
193
+ .harness-card.subtaskPreviewOpen:has(.liveSubtaskBody) {
194
+ height: var(--dees-harness-active-subtask-height, 320px);
195
+ }
196
+
173
197
  .harness-card.subtaskPreviewOpen .subtaskPreview {
174
198
  min-height: 0;
175
199
  height: 100%;
@@ -259,6 +283,15 @@ export class DeesHarnessToolCard extends DeesElement {
259
283
  font-size: 12px;
260
284
  }
261
285
 
286
+ /* Stands in for the body while a finished card is collapsed. */
287
+ .resultSummary {
288
+ display: block;
289
+ margin-top: 2px;
290
+ color: var(--dees-color-text-secondary);
291
+ font-family: var(--dees-font-family-mono, monospace);
292
+ font-size: 11px;
293
+ }
294
+
262
295
  .metaColumn {
263
296
  display: flex;
264
297
  align-items: center;
@@ -680,10 +713,27 @@ export class DeesHarnessToolCard extends DeesElement {
680
713
  && (this.call.subtask.status.type === 'busy' || this.call.subtask.status.type === 'error');
681
714
  }
682
715
  if (this.call.status === 'error' || this.call.isError) return true;
716
+ // A card that opened while it was live closes again once the work has settled, so a finished
717
+ // run stops holding transcript height. 'stopped' is deliberately excluded: a killed command
718
+ // keeps its partial output on screen, like an error does.
719
+ if (this.call.status === 'completed' && this.resolvedCollapseWhenFinished) return false;
683
720
  if (typeof descriptor.defaultExpanded === 'function') return descriptor.defaultExpanded(this.enrichedCall);
684
721
  return descriptor.defaultExpanded ?? false;
685
722
  }
686
723
 
724
+ private get resolvedCollapseWhenFinished(): boolean {
725
+ const collapseWhenFinished = this.descriptor.collapseWhenFinished;
726
+ if (typeof collapseWhenFinished === 'function') return collapseWhenFinished(this.enrichedCall);
727
+ return collapseWhenFinished ?? false;
728
+ }
729
+
730
+ /** Outcome line shown only while collapsed; the subtitle already carries the request. */
731
+ private get collapsedSummary(): string {
732
+ if (this.isOpen || !this.descriptor.summary) return '';
733
+ if (this.call.status !== 'completed' && this.call.status !== 'stopped') return '';
734
+ return this.descriptor.summary(this.enrichedCall).trim();
735
+ }
736
+
687
737
  private get hasSubtaskPreview(): boolean {
688
738
  return this.allowSubtaskStreams
689
739
  && this.descriptor.kind === 'subtask'
@@ -704,11 +754,19 @@ export class DeesHarnessToolCard extends DeesElement {
704
754
  const open = this.isOpen;
705
755
  const renderBody = this.bodyMounted || open;
706
756
  const label = typeof descriptor.label === 'function' ? descriptor.label(call) : descriptor.label;
707
- const subtitle = descriptor.subtitle?.(call) ?? '';
757
+ const derivedSubtitle = descriptor.subtitle?.(call) ?? '';
758
+ // A producer-supplied title describes the call in its own words — "Read SKILL.md" rather than
759
+ // the sed invocation behind it — so it leads, and the derived line stays as the tooltip.
760
+ const producerTitle = typeof call.title === 'string' ? call.title.trim() : '';
761
+ const subtitle = producerTitle || derivedSubtitle;
762
+ const subtitleTooltip = producerTitle && derivedSubtitle && derivedSubtitle !== producerTitle
763
+ ? `${producerTitle}\n${derivedSubtitle}`
764
+ : subtitle;
708
765
  const effectiveStatus = harnessEffectiveToolStatus(call);
709
766
  const statusTone = effectiveStatus === 'completed' ? 'success'
710
767
  : effectiveStatus === 'stopped' ? 'warning' : effectiveStatus;
711
768
  const duration = harnessFormatDuration(call.startedAt, call.finishedAt);
769
+ const collapsedSummary = this.collapsedSummary;
712
770
  return html`
713
771
  <div class="harness-card status-${effectiveStatus} ${descriptor.destructive ? 'destructive' : ''} ${this.hasSubtaskPreview ? 'subtaskPreviewCard' : ''} ${this.hasSubtaskPreview && open ? 'subtaskPreviewOpen' : ''} ${this.returnFlash ? 'returnFlash' : ''} ${this.outcomeScanTone ? `outcomeScan outcomeScan-${this.outcomeScanTone}` : ''}"
714
772
  @mouseenter=${this.cancelTerminalTailReturn}
@@ -734,7 +792,10 @@ export class DeesHarnessToolCard extends DeesElement {
734
792
  ${this.renderMcpBadges(call)}
735
793
  </span>
736
794
  ${subtitle
737
- ? html`<dees-harness-overflow-text class="subtitle" title=${subtitle}>${subtitle}</dees-harness-overflow-text>`
795
+ ? html`<dees-harness-overflow-text class="subtitle" title=${subtitleTooltip}>${subtitle}</dees-harness-overflow-text>`
796
+ : ''}
797
+ ${collapsedSummary
798
+ ? html`<dees-harness-overflow-text class="resultSummary" title=${collapsedSummary}>${collapsedSummary}</dees-harness-overflow-text>`
738
799
  : ''}
739
800
  </span>
740
801
  <span class="metaColumn">
@@ -1047,30 +1108,39 @@ export class DeesHarnessToolCard extends DeesElement {
1047
1108
  };
1048
1109
 
1049
1110
  private readonly syncSubtaskResultScroll = (): void => {
1050
- const result = this.subtaskResultElement;
1051
- if (!result) return;
1052
- result.classList.toggle('fadeTop', result.scrollTop > 1);
1053
- result.classList.toggle(
1054
- 'fadeBottom',
1055
- result.scrollHeight - result.scrollTop - result.clientHeight > 1,
1056
- );
1111
+ // Both panels hide their scrollbar, so the mask is the only cue that there is more text.
1112
+ // Leaving the brief unwired made a long brief look truncated rather than scrollable.
1113
+ for (const panel of this.subtaskDetailElements) {
1114
+ panel.classList.toggle('fadeTop', panel.scrollTop > 1);
1115
+ panel.classList.toggle(
1116
+ 'fadeBottom',
1117
+ panel.scrollHeight - panel.scrollTop - panel.clientHeight > 1,
1118
+ );
1119
+ }
1057
1120
  };
1058
1121
 
1059
1122
  private connectSubtaskResultScroll(): void {
1060
- const nextResult = this.shadowRoot?.querySelector<HTMLElement>(
1061
- '.harness-subtask-detail-result .harness-subtask-detail-text',
1062
- ) ?? null;
1063
- if (nextResult === this.subtaskResultElement) {
1123
+ const nextPanels = [...(this.shadowRoot?.querySelectorAll<HTMLElement>(
1124
+ '.harness-subtask-detail-brief .harness-subtask-detail-text,'
1125
+ + ' .harness-subtask-detail-result .harness-subtask-detail-text',
1126
+ ) ?? [])];
1127
+ const unchanged = nextPanels.length === this.subtaskDetailElements.length
1128
+ && nextPanels.every((panel, index) => panel === this.subtaskDetailElements[index]);
1129
+ if (unchanged) {
1064
1130
  this.syncSubtaskResultScroll();
1065
1131
  return;
1066
1132
  }
1067
- this.subtaskResultElement?.removeEventListener('scroll', this.syncSubtaskResultScroll);
1133
+ for (const panel of this.subtaskDetailElements) {
1134
+ panel.removeEventListener('scroll', this.syncSubtaskResultScroll);
1135
+ }
1068
1136
  this.subtaskResultResizeObserver?.disconnect();
1069
- this.subtaskResultElement = nextResult;
1070
- if (!nextResult) return;
1071
- nextResult.addEventListener('scroll', this.syncSubtaskResultScroll, { passive: true });
1137
+ this.subtaskDetailElements = nextPanels;
1138
+ if (!nextPanels.length) return;
1072
1139
  this.subtaskResultResizeObserver ??= new ResizeObserver(this.syncSubtaskResultScroll);
1073
- this.subtaskResultResizeObserver.observe(nextResult);
1140
+ for (const panel of nextPanels) {
1141
+ panel.addEventListener('scroll', this.syncSubtaskResultScroll, { passive: true });
1142
+ this.subtaskResultResizeObserver.observe(panel);
1143
+ }
1074
1144
  this.syncSubtaskResultScroll();
1075
1145
  }
1076
1146
 
@@ -1116,8 +1186,10 @@ export class DeesHarnessToolCard extends DeesElement {
1116
1186
  this.bodyResizeObserver?.disconnect();
1117
1187
  this.bodyResizeObserver = null;
1118
1188
  this.observedBodyInner = null;
1119
- this.subtaskResultElement?.removeEventListener('scroll', this.syncSubtaskResultScroll);
1120
- this.subtaskResultElement = null;
1189
+ for (const panel of this.subtaskDetailElements) {
1190
+ panel.removeEventListener('scroll', this.syncSubtaskResultScroll);
1191
+ }
1192
+ this.subtaskDetailElements = [];
1121
1193
  this.subtaskResultResizeObserver?.disconnect();
1122
1194
  this.subtaskResultResizeObserver = null;
1123
1195
  await super.disconnectedCallback();
@@ -6,7 +6,9 @@ import {
6
6
  harnessLanguageForPath,
7
7
  harnessParseTodos,
8
8
  harnessStringifyValue,
9
+ harnessStripAnsi,
9
10
  harnessSummarizeValue,
11
+ harnessTerminalOutcome,
10
12
  } from '../harness.helpers.js';
11
13
  import '../dees-harness-todos/dees-harness-todos.js';
12
14
 
@@ -125,35 +127,20 @@ export const applyPatchFirstPath = (call: IHarnessToolCall): string => {
125
127
  return match ? match[1].trim() : '';
126
128
  };
127
129
 
128
- // eslint-disable-next-line no-control-regex
129
- const ansiEscapePattern = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b[=>]/g;
130
-
131
- const stripAnsi = (text: string): string => text.replace(ansiEscapePattern, '');
132
-
133
130
  const renderTerminal = (call: IHarnessToolCall, helpers: IHarnessToolRenderHelpers): TemplateResult => {
134
131
  const input = inputRecord(call);
135
132
  const command = String(input.command ?? '');
136
- const output = call.output;
137
- let stdout = '';
138
- let stderr = '';
139
- let nestedExitCode: number | undefined;
140
- if (harnessIsRecord(output)) {
141
- stdout = typeof output.stdout === 'string' ? output.stdout : harnessStringifyValue(output.stdout ?? '');
142
- stderr = typeof output.stderr === 'string' ? output.stderr : '';
143
- nestedExitCode = typeof output.exitCode === 'number' ? output.exitCode : undefined;
144
- } else {
145
- stdout = harnessStringifyValue(output);
146
- }
147
- const exitCode = typeof call.exitCode === 'number' ? call.exitCode : nestedExitCode;
133
+ // Shared with the collapsed-card summary so both read the same outcome.
134
+ const { stdout, stderr, exitCode } = harnessTerminalOutcome(call);
148
135
  const running = call.status === 'pending' || call.status === 'running';
149
136
  return html`
150
137
  <div class="harness-terminal">
151
138
  ${command
152
139
  ? html`<div class="harness-terminal-command"><span class="prompt">$</span> ${command}</div>`
153
140
  : ''}
154
- ${stdout ? html`<pre class="harness-terminal-output">${stripAnsi(stdout)}</pre>` : ''}
155
- ${stderr ? html`<pre class="harness-terminal-output tone-error">${stripAnsi(stderr)}</pre>` : ''}
156
- ${call.errorText ? html`<pre class="harness-terminal-output tone-error">${stripAnsi(call.errorText)}</pre>` : ''}
141
+ ${stdout ? html`<pre class="harness-terminal-output">${harnessStripAnsi(stdout)}</pre>` : ''}
142
+ ${stderr ? html`<pre class="harness-terminal-output tone-error">${harnessStripAnsi(stderr)}</pre>` : ''}
143
+ ${call.errorText ? html`<pre class="harness-terminal-output tone-error">${harnessStripAnsi(call.errorText)}</pre>` : ''}
157
144
  ${running && !stdout && !stderr
158
145
  ? html`<div class="harness-terminal-running"><span class="cursor"></span> running…</div>`
159
146
  : ''}