@design.estate/dees-catalog 6.0.0 → 6.2.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 (45) hide show
  1. package/dist_bundle/bundle.js +3105 -1500
  2. package/dist_ts_web/00_commitinfo_data.js +1 -1
  3. package/dist_ts_web/elements/00group-harness/dees-harness-chat/dees-harness-chat.d.ts +2 -0
  4. package/dist_ts_web/elements/00group-harness/dees-harness-chat/dees-harness-chat.demo.js +484 -8
  5. package/dist_ts_web/elements/00group-harness/dees-harness-chat/dees-harness-chat.js +10 -1
  6. package/dist_ts_web/elements/00group-harness/dees-harness-message/dees-harness-message.d.ts +3 -1
  7. package/dist_ts_web/elements/00group-harness/dees-harness-message/dees-harness-message.js +24 -4
  8. package/dist_ts_web/elements/00group-harness/dees-harness-message-list/dees-harness-message-list.d.ts +26 -0
  9. package/dist_ts_web/elements/00group-harness/dees-harness-message-list/dees-harness-message-list.js +398 -94
  10. package/dist_ts_web/elements/00group-harness/dees-harness-overflow-text.d.ts +32 -0
  11. package/dist_ts_web/elements/00group-harness/dees-harness-overflow-text.demo.d.ts +1 -0
  12. package/dist_ts_web/elements/00group-harness/dees-harness-overflow-text.demo.js +31 -0
  13. package/dist_ts_web/elements/00group-harness/dees-harness-overflow-text.js +297 -0
  14. package/dist_ts_web/elements/00group-harness/dees-harness-session-list/dees-harness-session-list.js +1 -9
  15. package/dist_ts_web/elements/00group-harness/dees-harness-session-sidebar/dees-harness-session-sidebar.d.ts +10 -1
  16. package/dist_ts_web/elements/00group-harness/dees-harness-session-sidebar/dees-harness-session-sidebar.js +181 -2
  17. package/dist_ts_web/elements/00group-harness/dees-harness-tool-card/dees-harness-tool-card.d.ts +17 -1
  18. package/dist_ts_web/elements/00group-harness/dees-harness-tool-card/dees-harness-tool-card.js +480 -36
  19. package/dist_ts_web/elements/00group-harness/dees-harness-tool-card/renderers.js +28 -24
  20. package/dist_ts_web/elements/00group-harness/harness.helpers.d.ts +5 -1
  21. package/dist_ts_web/elements/00group-harness/harness.helpers.js +66 -1
  22. package/dist_ts_web/elements/00group-harness/index.d.ts +1 -0
  23. package/dist_ts_web/elements/00group-harness/index.js +2 -1
  24. package/dist_ts_web/elements/00group-harness/interfaces.d.ts +33 -2
  25. package/dist_ts_web/elements/00group-harness/toolregistry.d.ts +4 -0
  26. package/dist_ts_web/elements/00group-harness/toolregistry.js +8 -1
  27. package/package.json +2 -2
  28. package/readme.hints.md +16 -1
  29. package/readme.md +41 -9
  30. package/readme.playbook.md +8 -4
  31. package/ts_web/00_commitinfo_data.ts +1 -1
  32. package/ts_web/elements/00group-harness/dees-harness-chat/dees-harness-chat.demo.ts +490 -7
  33. package/ts_web/elements/00group-harness/dees-harness-chat/dees-harness-chat.ts +9 -0
  34. package/ts_web/elements/00group-harness/dees-harness-message/dees-harness-message.ts +24 -3
  35. package/ts_web/elements/00group-harness/dees-harness-message-list/dees-harness-message-list.ts +397 -84
  36. package/ts_web/elements/00group-harness/dees-harness-overflow-text.demo.ts +31 -0
  37. package/ts_web/elements/00group-harness/dees-harness-overflow-text.ts +236 -0
  38. package/ts_web/elements/00group-harness/dees-harness-session-list/dees-harness-session-list.ts +0 -8
  39. package/ts_web/elements/00group-harness/dees-harness-session-sidebar/dees-harness-session-sidebar.ts +193 -1
  40. package/ts_web/elements/00group-harness/dees-harness-tool-card/dees-harness-tool-card.ts +484 -34
  41. package/ts_web/elements/00group-harness/dees-harness-tool-card/renderers.ts +29 -25
  42. package/ts_web/elements/00group-harness/harness.helpers.ts +68 -1
  43. package/ts_web/elements/00group-harness/index.ts +1 -0
  44. package/ts_web/elements/00group-harness/interfaces.ts +38 -2
  45. package/ts_web/elements/00group-harness/toolregistry.ts +12 -0
@@ -11,9 +11,17 @@ import {
11
11
  directives,
12
12
  } from '@design.estate/dees-element';
13
13
  import { themeDefaultStyles } from '../../00theme.js';
14
- import type { IHarnessMessage, IHarnessPermissionRequest, IHarnessQuestionRequest, IHarnessStatus, THarnessStreamDelta } from '../interfaces.js';
14
+ import type {
15
+ IHarnessMessage,
16
+ IHarnessPermissionRequest,
17
+ IHarnessQuestionRequest,
18
+ IHarnessStatus,
19
+ THarnessMessageStreamDelta,
20
+ THarnessStreamDelta,
21
+ } from '../interfaces.js';
15
22
  import { DeesHarnessMessage } from '../dees-harness-message/dees-harness-message.js';
16
- import { DeesHarnessToolRegistry } from '../toolregistry.js';
23
+ import { DeesHarnessToolRegistry, type THarnessToolLayout } from '../toolregistry.js';
24
+ import { harnessApplyMessageDelta } from '../harness.helpers.js';
17
25
  import '../dees-harness-status/dees-harness-status.js';
18
26
  import '../dees-harness-permission-card/dees-harness-permission-card.js';
19
27
  import '../dees-harness-question-card/dees-harness-question-card.js';
@@ -29,11 +37,28 @@ declare global {
29
37
 
30
38
  const pinThresholdPx = 48;
31
39
 
40
+ type TMessageTimelineItem = {
41
+ kind: 'message';
42
+ time: number;
43
+ key: string;
44
+ message: IHarnessMessage;
45
+ toolLayout: THarnessToolLayout | null;
46
+ };
47
+
32
48
  type TTimelineItem =
33
- | { kind: 'message'; time: number; key: string; message: IHarnessMessage }
49
+ | TMessageTimelineItem
34
50
  | { kind: 'permission'; time: number; key: string; request: IHarnessPermissionRequest }
35
51
  | { kind: 'question'; time: number; key: string; question: IHarnessQuestionRequest };
36
52
 
53
+ interface IToolGroupTimelineItem {
54
+ kind: 'tool-group';
55
+ key: string;
56
+ layout: THarnessToolLayout;
57
+ messages: TMessageTimelineItem[];
58
+ }
59
+
60
+ type TTimelineRenderItem = Exclude<TTimelineItem, TMessageTimelineItem> | TMessageTimelineItem | IToolGroupTimelineItem;
61
+
37
62
  /**
38
63
  * Scrollable, streaming-safe message log. Messages render through a keyed
39
64
  * repeat (stable DOM per message id); `applyDelta()` routes incremental
@@ -67,9 +92,19 @@ export class DeesHarnessMessageList extends DeesElement {
67
92
  @property({ type: Boolean })
68
93
  accessor autoFollow: boolean = true;
69
94
 
95
+ @property({ type: Boolean })
96
+ accessor hideScrollbar: boolean = false;
97
+
98
+ @property({ type: Boolean })
99
+ accessor fadeScrollEdges: boolean = false;
100
+
70
101
  @property({ attribute: false })
71
102
  accessor toolRegistry: DeesHarnessToolRegistry = DeesHarnessToolRegistry.default;
72
103
 
104
+ /** Nested transcripts set this false to prevent recursive inline streams. */
105
+ @property({ type: Boolean })
106
+ accessor allowSubtaskStreams: boolean = true;
107
+
73
108
  @property({ type: Boolean })
74
109
  accessor showRoles: boolean = true;
75
110
 
@@ -88,6 +123,16 @@ export class DeesHarnessMessageList extends DeesElement {
88
123
  @state()
89
124
  accessor entryAnimationsEnabled: boolean = false;
90
125
 
126
+ @state()
127
+ accessor scrollFadeTop: boolean = false;
128
+
129
+ @state()
130
+ accessor scrollFadeBottom: boolean = false;
131
+
132
+ private readonly toolGroupIdByMessageId = new Map<string, string>();
133
+ private nextToolGroupId = 1;
134
+ private timelineRenderItems: TTimelineRenderItem[] = [];
135
+
91
136
  public static styles = [
92
137
  themeDefaultStyles,
93
138
  cssManager.defaultStyles,
@@ -104,10 +149,47 @@ export class DeesHarnessMessageList extends DeesElement {
104
149
  .scroll {
105
150
  height: 100%;
106
151
  overflow-y: auto;
152
+ overscroll-behavior: contain;
107
153
  scrollbar-width: thin;
108
154
  scrollbar-color: var(--dees-color-scrollbar-thumb) transparent;
109
155
  padding: var(--dees-spacing-md);
110
156
  box-sizing: border-box;
157
+ container: harness-transcript / inline-size;
158
+ }
159
+
160
+ .scroll.hideScrollbar {
161
+ scrollbar-width: none;
162
+ }
163
+
164
+ .scroll.hideScrollbar::-webkit-scrollbar {
165
+ display: none;
166
+ }
167
+
168
+ .scroll.fadeTop {
169
+ -webkit-mask-image: linear-gradient(to bottom, transparent 0, black 40px, black 100%);
170
+ mask-image: linear-gradient(to bottom, transparent 0, black 40px, black 100%);
171
+ }
172
+
173
+ .scroll.fadeBottom {
174
+ -webkit-mask-image: linear-gradient(to bottom, black 0, black calc(100% - 40px), transparent 100%);
175
+ mask-image: linear-gradient(to bottom, black 0, black calc(100% - 40px), transparent 100%);
176
+ }
177
+
178
+ .scroll.fadeTop.fadeBottom {
179
+ -webkit-mask-image: linear-gradient(
180
+ to bottom,
181
+ transparent 0,
182
+ black 40px,
183
+ black calc(100% - 40px),
184
+ transparent 100%
185
+ );
186
+ mask-image: linear-gradient(
187
+ to bottom,
188
+ transparent 0,
189
+ black 40px,
190
+ black calc(100% - 40px),
191
+ transparent 100%
192
+ );
111
193
  }
112
194
 
113
195
  /* observed by the follow ResizeObserver; fills the viewport so the
@@ -118,14 +200,62 @@ export class DeesHarnessMessageList extends DeesElement {
118
200
  flex-direction: column;
119
201
  }
120
202
 
121
- .stack {
203
+ ::slotted([slot='leading']) {
122
204
  display: block;
205
+ min-width: 0;
206
+ margin-bottom: var(--dees-spacing-sm);
123
207
  }
124
208
 
125
- /* spacing via sibling margins (not grid gap) so a new entry's margin
126
- animates together with its height while it fades in */
127
- .stack > * + * {
128
- margin-top: var(--dees-spacing-sm);
209
+ .stack {
210
+ display: grid;
211
+ grid-template-columns: minmax(0, 1fr);
212
+ gap: var(--dees-spacing-sm);
213
+ }
214
+
215
+ .stack > * {
216
+ min-width: 0;
217
+ grid-column: 1 / -1;
218
+ }
219
+
220
+ .toolGroup {
221
+ min-width: 0;
222
+ }
223
+
224
+ .toolGrid {
225
+ display: grid;
226
+ grid-template-columns: minmax(0, 1fr);
227
+ gap: var(--dees-spacing-sm);
228
+ min-width: 0;
229
+ }
230
+
231
+ .toolGrid > dees-harness-message {
232
+ min-width: 0;
233
+ align-self: start;
234
+ }
235
+
236
+ .toolGrid > dees-harness-message[data-tool-layout='full-row'] {
237
+ grid-column: 1 / -1;
238
+ }
239
+
240
+ @container harness-transcript (min-width: 760px) {
241
+ .toolGrid.toolCount2,
242
+ .toolGrid.toolCount3,
243
+ .toolGrid.toolCount4 {
244
+ grid-template-columns: repeat(2, minmax(0, 1fr));
245
+ }
246
+ }
247
+
248
+ @container harness-transcript (min-width: 1120px) {
249
+ .toolGrid.toolCount3,
250
+ .toolGrid.toolCount4 {
251
+ grid-template-columns: repeat(3, minmax(0, 1fr));
252
+ }
253
+ }
254
+
255
+ @container harness-transcript (min-width: 1480px) {
256
+ .toolGrid.toolCount4 {
257
+ grid-template-columns: repeat(4, minmax(0, 1fr));
258
+ }
129
259
  }
130
260
 
131
261
  .statusRow {
@@ -175,35 +305,19 @@ export class DeesHarnessMessageList extends DeesElement {
175
305
  .jumpChip:hover {
176
306
  background: var(--dees-material-thick-bg);
177
307
  }
308
+
178
309
  `,
179
310
  ];
180
311
 
181
312
  /** messages and permissions merged chronologically (stable ids as keys) */
182
313
  private get timelineItems(): TTimelineItem[] {
183
- // Running subagents pin near the end — live activity belongs where the
184
- // eye is — ordered by start time among themselves, and still ABOVE
185
- // pending permission/question cards (those demand action). On completion
186
- // a subagent returns to its chronological position in the flow.
187
- // Stale ones do not pin: a "running" call is orphaned when the session
188
- // itself is idle (nothing can still be running it), or ancient when 20+
189
- // messages have passed it by.
190
- const runningSubagentBase = Number.MAX_SAFE_INTEGER / 2;
191
- const sessionActive = this.status?.type === 'busy';
192
- const items: TTimelineItem[] = this.messages.map((message, index) => {
193
- const call = message.toolCall;
194
- const runningSubagent = Boolean(call)
195
- && (call!.name === 'task' || call!.name === 'subagent' || call!.kind === 'subtask')
196
- && (call!.status === 'pending' || call!.status === 'running');
197
- const stale = !sessionActive || this.messages.length - 1 - index >= 20;
198
- return {
199
- kind: 'message' as const,
200
- time: runningSubagent && !stale
201
- ? runningSubagentBase + (message.createdAt || 0)
202
- : message.createdAt || 0,
203
- key: message.id,
204
- message,
205
- };
206
- });
314
+ const items: TTimelineItem[] = this.messages.map((message) => ({
315
+ kind: 'message' as const,
316
+ time: message.createdAt || 0,
317
+ key: `message:${message.id}`,
318
+ message,
319
+ toolLayout: message.toolCall ? this.toolRegistry.resolveLayout(message.toolCall) : null,
320
+ }));
207
321
  for (const request of this.permissions) {
208
322
  items.push({
209
323
  kind: 'permission' as const,
@@ -212,7 +326,7 @@ export class DeesHarnessMessageList extends DeesElement {
212
326
  time: request.response
213
327
  ? (request.respondedAt ?? request.createdAt ?? 0)
214
328
  : Number.MAX_SAFE_INTEGER,
215
- key: `perm:${request.id}`,
329
+ key: `permission:${request.id}`,
216
330
  request,
217
331
  });
218
332
  }
@@ -229,33 +343,93 @@ export class DeesHarnessMessageList extends DeesElement {
229
343
  return items.sort((left, right) => left.time - right.time);
230
344
  }
231
345
 
232
- public render(): TemplateResult {
346
+ private buildTimelineRenderItems(): TTimelineRenderItem[] {
233
347
  const items = this.timelineItems;
348
+ const retainedToolIds = new Set(
349
+ items
350
+ .filter((item): item is TMessageTimelineItem => item.kind === 'message' && item.toolLayout !== null)
351
+ .map((item) => item.message.id),
352
+ );
353
+ for (const messageId of this.toolGroupIdByMessageId.keys()) {
354
+ if (!retainedToolIds.has(messageId)) this.toolGroupIdByMessageId.delete(messageId);
355
+ }
356
+
357
+ const result: TTimelineRenderItem[] = [];
358
+ const usedGroupIds = new Set<string>();
359
+ for (let index = 0; index < items.length;) {
360
+ const item = items[index];
361
+ if (item.kind !== 'message' || item.toolLayout === null) {
362
+ result.push(item);
363
+ index += 1;
364
+ continue;
365
+ }
366
+
367
+ const layout = item.toolLayout;
368
+ const run: TMessageTimelineItem[] = [];
369
+ while (index < items.length) {
370
+ const candidate = items[index];
371
+ if (
372
+ candidate.kind !== 'message'
373
+ || candidate.toolLayout !== layout
374
+ ) break;
375
+ run.push(candidate);
376
+ index += 1;
377
+ }
378
+ this.appendToolRun(result, run, layout, usedGroupIds);
379
+ }
380
+ return result;
381
+ }
382
+
383
+ public willUpdate(changedProperties: Map<PropertyKey, unknown>): void {
384
+ super.willUpdate(changedProperties);
385
+ this.timelineRenderItems = this.buildTimelineRenderItems();
386
+ }
387
+
388
+ private appendToolRun(
389
+ targetArg: TTimelineRenderItem[],
390
+ runArg: TMessageTimelineItem[],
391
+ layoutArg: THarnessToolLayout,
392
+ usedGroupIdsArg: Set<string>,
393
+ ): void {
394
+ const groupId = runArg
395
+ .map((item) => this.toolGroupIdByMessageId.get(item.message.id))
396
+ .find((candidate): candidate is string => candidate !== undefined && !usedGroupIdsArg.has(candidate))
397
+ ?? `tool-group-${this.nextToolGroupId++}`;
398
+ usedGroupIdsArg.add(groupId);
399
+ for (const item of runArg) {
400
+ this.toolGroupIdByMessageId.set(item.message.id, groupId);
401
+ }
402
+ targetArg.push({
403
+ kind: 'tool-group',
404
+ key: groupId,
405
+ layout: layoutArg,
406
+ messages: runArg,
407
+ });
408
+ }
409
+
410
+ public render(): TemplateResult {
411
+ const items = this.timelineRenderItems;
234
412
  return html`
235
413
  <div
236
- class="scroll"
414
+ class=${`scroll ${this.hideScrollbar ? 'hideScrollbar' : ''} ${this.scrollFadeTop ? 'fadeTop' : ''} ${this.scrollFadeBottom ? 'fadeBottom' : ''}`}
237
415
  @scroll=${this.handleScroll}
238
416
  @wheel=${this.handleUserScrollIntent}
239
417
  @touchmove=${this.handleUserScrollIntent}
418
+ @pointerdown=${this.handleUserScrollIntent}
419
+ @keydown=${this.handleUserScrollIntent}
240
420
  >
241
421
  <div class="scrollContent">
422
+ <slot name="leading"></slot>
242
423
  ${items.length
243
424
  ? html`
244
425
  <div class="stack">
245
426
  ${repeat(
246
427
  items,
247
428
  (item) => item.key,
248
- (item) => item.kind === 'message'
249
- ? html`
250
- <dees-harness-message
251
- data-message-id=${item.message.id}
252
- .message=${item.message}
253
- .showRole=${this.showRoles}
254
- .showTimestamp=${this.showTimestamps}
255
- .toolRegistry=${this.toolRegistry}
256
- .suppressEntry=${!this.entryAnimationsEnabled}
257
- ></dees-harness-message>
258
- `
429
+ (item) => item.kind === 'tool-group'
430
+ ? this.renderToolGroup(item)
431
+ : item.kind === 'message'
432
+ ? this.renderMessage(item)
259
433
  : item.kind === 'permission'
260
434
  ? html`
261
435
  <dees-harness-permission-card
@@ -296,6 +470,41 @@ export class DeesHarnessMessageList extends DeesElement {
296
470
  `;
297
471
  }
298
472
 
473
+ private renderMessage(itemArg: TMessageTimelineItem): TemplateResult {
474
+ return html`
475
+ <dees-harness-message
476
+ data-message-id=${itemArg.message.id}
477
+ data-tool-layout=${itemArg.toolLayout ?? 'message'}
478
+ .message=${itemArg.message}
479
+ .showRole=${this.showRoles}
480
+ .showTimestamp=${this.showTimestamps}
481
+ .toolRegistry=${this.toolRegistry}
482
+ .allowSubtaskStreams=${this.allowSubtaskStreams}
483
+ .suppressEntry=${!this.entryAnimationsEnabled}
484
+ ></dees-harness-message>
485
+ `;
486
+ }
487
+
488
+ private renderToolGroup(groupArg: IToolGroupTimelineItem): TemplateResult {
489
+ const count = groupArg.messages.length;
490
+ const countClass = count >= 4 ? 'toolCount4' : `toolCount${count}`;
491
+ return html`
492
+ <section
493
+ class="toolGroup"
494
+ data-tool-group-id=${groupArg.key}
495
+ data-tool-layout=${groupArg.layout}
496
+ >
497
+ <div class=${`toolGrid ${countClass}`}>
498
+ ${repeat(
499
+ groupArg.messages,
500
+ (item) => item.key,
501
+ (item) => this.renderMessage(item),
502
+ )}
503
+ </div>
504
+ </section>
505
+ `;
506
+ }
507
+
299
508
  // ------------------------------------------------------------------ deltas
300
509
 
301
510
  /**
@@ -304,51 +513,50 @@ export class DeesHarnessMessageList extends DeesElement {
304
513
  * idempotent; only the target element re-renders.
305
514
  */
306
515
  public applyDelta(delta: THarnessStreamDelta): void {
516
+ if (delta.parentMessageId) {
517
+ if (!this.allowSubtaskStreams) return;
518
+ const parentMessage = this.messages.find((candidate) => candidate.id === delta.parentMessageId);
519
+ if (!parentMessage) return;
520
+ const parentElement = this.messageElement(delta.parentMessageId);
521
+ const { parentMessageId: _parentMessageId, ...childDelta } = delta;
522
+ const localDelta = childDelta as THarnessMessageStreamDelta;
523
+ const appliedByElement = parentElement?.applySubtaskDelta(localDelta) ?? false;
524
+ if (!appliedByElement && parentMessage.toolCall?.subtask?.previewState === 'ready') {
525
+ if (harnessApplyMessageDelta(parentMessage.toolCall.subtask.messages, localDelta)) {
526
+ parentElement?.requestUpdate();
527
+ }
528
+ }
529
+ this.followAfterContentChange();
530
+ return;
531
+ }
307
532
  const message = this.messages.find((candidate) => candidate.id === delta.messageId);
308
533
  if (!message) return;
309
534
  const element = this.messageElement(delta.messageId);
310
535
  switch (delta.type) {
311
536
  case 'text':
312
537
  if (element) element.appendTextDelta(delta.delta);
313
- else {
314
- message.text += delta.delta;
315
- message.streaming = true;
316
- }
538
+ else harnessApplyMessageDelta(this.messages, delta);
317
539
  break;
318
540
  case 'reasoning-start':
319
541
  if (element) element.appendReasoningDelta(delta.partId, '', delta.startedAt);
320
- else {
321
- message.reasoning ??= [];
322
- message.reasoning.push({ id: delta.partId, text: '', startedAt: delta.startedAt ?? Date.now() });
323
- }
542
+ else harnessApplyMessageDelta(this.messages, delta);
324
543
  break;
325
544
  case 'reasoning':
326
545
  if (element) element.appendReasoningDelta(delta.partId, delta.delta);
327
- else {
328
- message.reasoning ??= [];
329
- const part = message.reasoning.find((candidate) => candidate.id === delta.partId);
330
- if (part) part.text += delta.delta;
331
- else message.reasoning.push({ id: delta.partId, text: delta.delta, startedAt: Date.now() });
332
- }
546
+ else harnessApplyMessageDelta(this.messages, delta);
333
547
  break;
334
548
  case 'reasoning-end':
335
549
  if (element) element.endReasoning(delta.partId, delta.endedAt);
336
- else {
337
- const part = message.reasoning?.find((candidate) => candidate.id === delta.partId);
338
- if (part) part.endedAt = delta.endedAt ?? Date.now();
339
- }
550
+ else harnessApplyMessageDelta(this.messages, delta);
340
551
  break;
341
552
  case 'tool-update':
342
553
  message.toolCall = { ...(message.toolCall ?? { id: delta.tool.id, name: '', status: 'pending' }), ...delta.tool };
343
554
  element?.requestUpdate();
555
+ this.requestUpdate();
344
556
  break;
345
557
  case 'message-end':
346
558
  if (element) element.endStream(delta.usage, delta.error);
347
- else {
348
- message.streaming = false;
349
- if (delta.usage) message.usage = delta.usage;
350
- if (delta.error) message.error = delta.error;
351
- }
559
+ else harnessApplyMessageDelta(this.messages, delta);
352
560
  break;
353
561
  }
354
562
  this.followAfterContentChange();
@@ -372,8 +580,14 @@ export class DeesHarnessMessageList extends DeesElement {
372
580
  }
373
581
 
374
582
  private contentResizeObserver: ResizeObserver | null = null;
583
+ private observedScrollContent: Element | null = null;
584
+ private observedContentBlockSize: number | null = null;
585
+ private followFrame: number | null = null;
586
+ private followSecondFrame: number | null = null;
587
+ private entryFrame: number | null = null;
588
+ private entrySecondFrame: number | null = null;
375
589
  // Intent-based following: the list stays pinned until the user explicitly
376
- // moves away from the bottom (wheel/touch upward or a scrollbar drag), and
590
+ // moves away from the bottom (wheel/touch/keyboard or a scrollbar drag), and
377
591
  // re-pins the moment they return. Late layout growth — async markdown
378
592
  // parses, images, entry animations — re-pins through the content
379
593
  // ResizeObserver for as long as the user has not left the bottom, so
@@ -395,13 +609,31 @@ export class DeesHarnessMessageList extends DeesElement {
395
609
  this.showJumpChip = true;
396
610
  return;
397
611
  }
398
- this.repinToBottom();
399
- // catch the first post-render layout; later growth re-pins via the observer
400
- requestAnimationFrame(() => requestAnimationFrame(this.repinToBottom));
612
+ this.repinAfterContentGrowth();
613
+ // Catch post-render growth; later asynchronous growth uses the observer.
614
+ if (this.followFrame !== null || this.followSecondFrame !== null) return;
615
+ this.followFrame = requestAnimationFrame(() => {
616
+ this.followFrame = null;
617
+ if (!this.isConnected) return;
618
+ this.repinAfterContentGrowth();
619
+ this.followSecondFrame = requestAnimationFrame(() => {
620
+ this.followSecondFrame = null;
621
+ if (!this.isConnected) return;
622
+ this.repinAfterContentGrowth();
623
+ });
624
+ });
401
625
  }
402
626
 
403
627
  private handleUserScrollIntent = (event: Event): void => {
628
+ if (event.composedPath().some(
629
+ (candidate) => candidate instanceof DeesHarnessMessageList && candidate !== this,
630
+ )) return;
404
631
  if (event.type === 'wheel' && (event as WheelEvent).deltaY >= 0) return;
632
+ if (
633
+ event.type === 'keydown'
634
+ && !['ArrowUp', 'PageUp', 'Home'].includes((event as KeyboardEvent).key)
635
+ ) return;
636
+ if (event.type === 'pointerdown' && event.target !== this.scrollContainer) return;
405
637
  this.userUnpinned = true;
406
638
  };
407
639
 
@@ -416,21 +648,20 @@ export class DeesHarnessMessageList extends DeesElement {
416
648
  this.lastObservedScrollTop = container.scrollTop;
417
649
  this.showJumpChip = false;
418
650
  }
651
+ this.syncScrollEdgeFades();
419
652
  }
420
653
 
421
654
  private handleScroll = (): void => {
422
655
  const container = this.scrollContainer;
423
656
  if (!container) return;
424
657
  const top = container.scrollTop;
658
+ this.syncScrollEdgeFades();
425
659
  if (this.isPinnedToBottom) {
426
660
  // reaching the bottom — by any means — resumes following
427
661
  this.userUnpinned = false;
428
662
  if (this.showJumpChip) {
429
663
  this.showJumpChip = false;
430
664
  }
431
- } else if (top < this.lastObservedScrollTop - 1) {
432
- // moved up without our doing (scrollbar drag, keyboard): stop following
433
- this.userUnpinned = true;
434
665
  }
435
666
  this.lastObservedScrollTop = top;
436
667
  };
@@ -440,26 +671,108 @@ export class DeesHarnessMessageList extends DeesElement {
440
671
  if (changedProperties.has('messages') || changedProperties.has('status') || changedProperties.has('permissions') || changedProperties.has('questions')) {
441
672
  this.followAfterContentChange();
442
673
  }
674
+ if (changedProperties.has('autoFollow')) {
675
+ if (this.autoFollow) {
676
+ this.connectContentResizeObserver();
677
+ this.scrollToBottom(true);
678
+ }
679
+ else this.disconnectContentResizeObserver();
680
+ }
681
+ if (
682
+ changedProperties.has('fadeScrollEdges')
683
+ || changedProperties.has('messages')
684
+ || changedProperties.has('permissions')
685
+ || changedProperties.has('questions')
686
+ ) this.syncScrollEdgeFades();
443
687
  }
444
688
 
445
689
  public firstUpdated(changedProperties: Map<string, any>): void {
446
690
  super.firstUpdated(changedProperties);
447
- const content = this.shadowRoot?.querySelector('.scrollContent');
448
- if (content) {
449
- this.contentResizeObserver = new ResizeObserver(this.repinToBottom);
450
- this.contentResizeObserver.observe(content);
451
- }
691
+ if (!this.isConnected) return;
692
+ this.connectContentResizeObserver();
693
+ this.repinToBottom();
694
+ this.syncScrollEdgeFades();
452
695
  // let the initial batch paint without entry animations, then enable them
453
- requestAnimationFrame(() => {
454
- requestAnimationFrame(() => {
696
+ this.entryFrame = requestAnimationFrame(() => {
697
+ this.entryFrame = null;
698
+ if (!this.isConnected) return;
699
+ this.entrySecondFrame = requestAnimationFrame(() => {
700
+ this.entrySecondFrame = null;
701
+ if (!this.isConnected) return;
455
702
  this.entryAnimationsEnabled = true;
456
703
  });
457
704
  });
458
705
  }
459
706
 
460
- public async disconnectedCallback(): Promise<void> {
707
+ public async connectedCallback(): Promise<void> {
708
+ const connected = super.connectedCallback();
709
+ if (this.hasUpdated) this.connectContentResizeObserver();
710
+ await connected;
711
+ }
712
+
713
+ private connectContentResizeObserver(): void {
714
+ if (!this.isConnected || !this.autoFollow) return;
715
+ const content = this.shadowRoot?.querySelector('.scrollContent');
716
+ if (!content || (this.contentResizeObserver && content === this.observedScrollContent)) return;
717
+ this.contentResizeObserver?.disconnect();
718
+ this.observedContentBlockSize = content.getBoundingClientRect().height;
719
+ this.contentResizeObserver = new ResizeObserver((entries) => {
720
+ const entry = entries.find((candidate) => candidate.target === this.observedScrollContent);
721
+ if (!entry) return;
722
+ const blockSize = entry.borderBoxSize[0]?.blockSize ?? entry.contentRect.height;
723
+ this.handleContentBlockSize(blockSize);
724
+ });
725
+ this.contentResizeObserver.observe(content);
726
+ this.observedScrollContent = content;
727
+ }
728
+
729
+ private repinAfterContentGrowth(): void {
730
+ const content = this.observedScrollContent;
731
+ if (!content) return;
732
+ this.handleContentBlockSize(content.getBoundingClientRect().height);
733
+ }
734
+
735
+ private handleContentBlockSize(blockSizeArg: number): void {
736
+ const previousBlockSize = this.observedContentBlockSize;
737
+ this.observedContentBlockSize = blockSizeArg;
738
+ // Disclosure collapse and window eviction are not new streamed content.
739
+ // Let the browser handle shrink instead of fighting it with repins.
740
+ if (previousBlockSize !== null && blockSizeArg > previousBlockSize + 0.5) {
741
+ this.repinToBottom();
742
+ }
743
+ this.syncScrollEdgeFades();
744
+ }
745
+
746
+ private syncScrollEdgeFades(): void {
747
+ const container = this.scrollContainer;
748
+ const fadeTop = Boolean(this.fadeScrollEdges && container && container.scrollTop > 1);
749
+ const fadeBottom = Boolean(
750
+ this.fadeScrollEdges
751
+ && container
752
+ && container.scrollHeight - container.scrollTop - container.clientHeight > 1,
753
+ );
754
+ if (this.scrollFadeTop !== fadeTop) this.scrollFadeTop = fadeTop;
755
+ if (this.scrollFadeBottom !== fadeBottom) this.scrollFadeBottom = fadeBottom;
756
+ }
757
+
758
+ private disconnectContentResizeObserver(): void {
461
759
  this.contentResizeObserver?.disconnect();
462
760
  this.contentResizeObserver = null;
761
+ this.observedScrollContent = null;
762
+ this.observedContentBlockSize = null;
763
+ }
764
+
765
+ public async disconnectedCallback(): Promise<void> {
766
+ if (this.followFrame !== null) cancelAnimationFrame(this.followFrame);
767
+ if (this.followSecondFrame !== null) cancelAnimationFrame(this.followSecondFrame);
768
+ if (this.entryFrame !== null) cancelAnimationFrame(this.entryFrame);
769
+ if (this.entrySecondFrame !== null) cancelAnimationFrame(this.entrySecondFrame);
770
+ this.followFrame = null;
771
+ this.followSecondFrame = null;
772
+ this.entryFrame = null;
773
+ this.entrySecondFrame = null;
774
+ this.entryAnimationsEnabled = true;
775
+ this.disconnectContentResizeObserver();
463
776
  await super.disconnectedCallback();
464
777
  }
465
778
  }
@@ -0,0 +1,31 @@
1
+ import { html } from '@design.estate/dees-element';
2
+
3
+ export const demoFunc = () => html`
4
+ <style>
5
+ .overflowTextDemo {
6
+ display: grid;
7
+ gap: 16px;
8
+ max-width: 360px;
9
+ margin: 40px auto;
10
+ padding: 24px;
11
+ border-radius: var(--dees-radius-xl);
12
+ background: var(--dees-color-bg-secondary);
13
+ }
14
+ .overflowTextDemo .label {
15
+ color: var(--dees-color-text-muted);
16
+ font-size: 11px;
17
+ text-transform: uppercase;
18
+ letter-spacing: 0.05em;
19
+ }
20
+ </style>
21
+ <div class="overflowTextDemo">
22
+ <div class="label">short text</div>
23
+ <dees-harness-overflow-text>
24
+ Fits without a fade
25
+ </dees-harness-overflow-text>
26
+ <div class="label">overflowing text</div>
27
+ <dees-harness-overflow-text>
28
+ This long activity description reveals its hidden ending after the initial delay or on hover
29
+ </dees-harness-overflow-text>
30
+ </div>
31
+ `;