@design.estate/dees-catalog 6.7.0 → 6.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 (34) hide show
  1. package/dist_bundle/bundle.js +1153 -193
  2. package/dist_ts_web/00_commitinfo_data.js +1 -1
  3. package/dist_ts_web/elements/00group-harness/dees-harness-message/dees-harness-message.d.ts +2 -1
  4. package/dist_ts_web/elements/00group-harness/dees-harness-message/dees-harness-message.js +57 -17
  5. package/dist_ts_web/elements/00group-harness/dees-harness-message-list/dees-harness-message-list.d.ts +92 -6
  6. package/dist_ts_web/elements/00group-harness/dees-harness-message-list/dees-harness-message-list.js +921 -49
  7. package/dist_ts_web/elements/00group-harness/dees-harness-permission-card/dees-harness-permission-card.d.ts +5 -1
  8. package/dist_ts_web/elements/00group-harness/dees-harness-permission-card/dees-harness-permission-card.js +42 -7
  9. package/dist_ts_web/elements/00group-harness/dees-harness-question-card/dees-harness-question-card.d.ts +6 -1
  10. package/dist_ts_web/elements/00group-harness/dees-harness-question-card/dees-harness-question-card.js +53 -14
  11. package/dist_ts_web/elements/00group-harness/dees-harness-reasoning/dees-harness-reasoning.d.ts +2 -1
  12. package/dist_ts_web/elements/00group-harness/dees-harness-reasoning/dees-harness-reasoning.js +13 -3
  13. package/dist_ts_web/elements/00group-harness/dees-harness-session-list/dees-harness-session-list.js +31 -13
  14. package/dist_ts_web/elements/00group-harness/dees-harness-session-sidebar/dees-harness-session-sidebar.js +9 -3
  15. package/dist_ts_web/elements/00group-harness/dees-harness-tool-card/dees-harness-tool-card.d.ts +6 -1
  16. package/dist_ts_web/elements/00group-harness/dees-harness-tool-card/dees-harness-tool-card.js +135 -13
  17. package/dist_ts_web/elements/00group-harness/harness.styles.js +16 -13
  18. package/dist_ts_web/elements/00group-harness/interfaces.d.ts +30 -0
  19. package/dist_ts_web/elements/00group-harness/toolregistry.d.ts +2 -0
  20. package/dist_ts_web/elements/00group-harness/toolregistry.js +7 -1
  21. package/package.json +2 -2
  22. package/readme.md +4 -2
  23. package/ts_web/00_commitinfo_data.ts +1 -1
  24. package/ts_web/elements/00group-harness/dees-harness-message/dees-harness-message.ts +53 -15
  25. package/ts_web/elements/00group-harness/dees-harness-message-list/dees-harness-message-list.ts +943 -51
  26. package/ts_web/elements/00group-harness/dees-harness-permission-card/dees-harness-permission-card.ts +37 -8
  27. package/ts_web/elements/00group-harness/dees-harness-question-card/dees-harness-question-card.ts +40 -12
  28. package/ts_web/elements/00group-harness/dees-harness-reasoning/dees-harness-reasoning.ts +6 -2
  29. package/ts_web/elements/00group-harness/dees-harness-session-list/dees-harness-session-list.ts +30 -12
  30. package/ts_web/elements/00group-harness/dees-harness-session-sidebar/dees-harness-session-sidebar.ts +8 -2
  31. package/ts_web/elements/00group-harness/dees-harness-tool-card/dees-harness-tool-card.ts +119 -11
  32. package/ts_web/elements/00group-harness/harness.styles.ts +15 -12
  33. package/ts_web/elements/00group-harness/interfaces.ts +34 -0
  34. package/ts_web/elements/00group-harness/toolregistry.ts +7 -0
@@ -13,9 +13,13 @@ import {
13
13
  import { themeDefaultStyles } from '../../00theme.js';
14
14
  import type {
15
15
  IHarnessMessage,
16
+ IHarnessMessageViewState,
16
17
  IHarnessPermissionRequest,
18
+ IHarnessPermissionViewState,
17
19
  IHarnessQuestionRequest,
20
+ IHarnessQuestionViewState,
18
21
  IHarnessStatus,
22
+ IHarnessTranscriptViewState,
19
23
  THarnessMessageStreamDelta,
20
24
  THarnessStreamDelta,
21
25
  } from '../interfaces.js';
@@ -36,6 +40,10 @@ declare global {
36
40
  }
37
41
 
38
42
  const pinThresholdPx = 48;
43
+ const virtualizationThreshold = 40;
44
+ const virtualOverscanPx = 800;
45
+ const virtualGapPx = 8;
46
+ const maximumToolMessagesPerGroup = 4;
39
47
 
40
48
  type TMessageTimelineItem = {
41
49
  kind: 'message';
@@ -73,11 +81,10 @@ interface IToolGroupTimelineItem {
73
81
  type TTimelineRenderItem = Exclude<TTimelineItem, TMessageTimelineItem> | TMessageTimelineItem | IToolGroupTimelineItem;
74
82
 
75
83
  /**
76
- * Scrollable, streaming-safe message log. Messages render through a keyed
77
- * repeat (stable DOM per message id); `applyDelta()` routes incremental
78
- * updates to the affected message element only. Auto-follows the bottom
79
- * while the user is pinned there; otherwise new content raises a
80
- * "jump to latest" chip instead of yanking the scroll position.
84
+ * Scrollable, streaming-safe message log. Short transcripts keep stable keyed
85
+ * DOM; long transcripts mount a bounded, measured window and retain row state
86
+ * by id. `applyDelta()` targets a mounted row or updates its offscreen model.
87
+ * Auto-follow stays at the bottom until the user intentionally scrolls away.
81
88
  */
82
89
  @customElement('dees-harness-message-list')
83
90
  export class DeesHarnessMessageList extends DeesElement {
@@ -114,6 +121,9 @@ export class DeesHarnessMessageList extends DeesElement {
114
121
  @property({ attribute: false })
115
122
  accessor toolRegistry: DeesHarnessToolRegistry = DeesHarnessToolRegistry.default;
116
123
 
124
+ @property({ attribute: false })
125
+ accessor viewState: IHarnessTranscriptViewState | undefined = undefined;
126
+
117
127
  /** Nested transcripts set this false to prevent recursive inline streams. */
118
128
  @property({ type: Boolean })
119
129
  accessor allowSubtaskStreams: boolean = true;
@@ -145,6 +155,47 @@ export class DeesHarnessMessageList extends DeesElement {
145
155
  private readonly toolGroupIdByMessageId = new Map<string, string>();
146
156
  private nextToolGroupId = 1;
147
157
  private timelineRenderItems: TTimelineRenderItem[] = [];
158
+ private timelineRegistryRevision = -1;
159
+ private readonly knownContentKeys = new Set<string>();
160
+ private currentNewContentKeys = new Set<string>();
161
+ private readonly localViewState: IHarnessTranscriptViewState = {
162
+ messages: {},
163
+ permissions: {},
164
+ questions: {},
165
+ };
166
+ private readonly measuredVirtualHeights = new Map<string, number>();
167
+ private timelineIndexByKey = new Map<string, number>();
168
+ private virtualOffsets: number[] = [];
169
+ private virtualTotalHeight = 0;
170
+ private virtualStartIndex = 0;
171
+ private virtualEndIndex = 0;
172
+ private retainedVirtualKeys = new Set<string>();
173
+ private selectionGestureKeys = new Set<string>();
174
+ private selectionGestureActive = false;
175
+ private selectionGestureStartOwner: Element | undefined;
176
+ private selectionGestureCrossesOwners = false;
177
+ private selectionGestureSnapshot: {
178
+ anchorNode: Node | null;
179
+ anchorOffset: number;
180
+ focusNode: Node | null;
181
+ focusOffset: number;
182
+ rangeCount: number;
183
+ } | undefined;
184
+ private explicitRetainedVirtualKey: string | undefined;
185
+ private virtualRowResizeObserver: ResizeObserver | null = null;
186
+ private virtualViewportResizeObserver: ResizeObserver | null = null;
187
+ private virtualMeasureFrame: number | null = null;
188
+ private virtualRangeFrame: number | null = null;
189
+ private lastVirtualViewportWidth = 0;
190
+ private pendingVirtualAnchor: { key: string; offset: number } | undefined;
191
+ private pendingFullAnchor: { key: string; offset: number } | undefined;
192
+ private measurementAnchor: { key: string; offset: number } | undefined;
193
+ private pendingVirtualRepin = false;
194
+ private virtualizationEnabled = false;
195
+ private virtualizationEnablePending = false;
196
+ private virtualizationDisablePending = false;
197
+ private virtualizationActivationFrame: number | null = null;
198
+ private selectionChangeListening = false;
148
199
 
149
200
  public static styles = [
150
201
  themeDefaultStyles,
@@ -162,6 +213,7 @@ export class DeesHarnessMessageList extends DeesElement {
162
213
  .scroll {
163
214
  height: 100%;
164
215
  overflow-y: auto;
216
+ overflow-anchor: none;
165
217
  overscroll-behavior: contain;
166
218
  scrollbar-width: thin;
167
219
  scrollbar-color: var(--dees-color-scrollbar-thumb) transparent;
@@ -230,6 +282,19 @@ export class DeesHarnessMessageList extends DeesElement {
230
282
  grid-column: 1 / -1;
231
283
  }
232
284
 
285
+ .virtualStack {
286
+ position: relative;
287
+ flex: 0 0 auto;
288
+ min-width: 0;
289
+ }
290
+
291
+ .virtualItem {
292
+ position: absolute;
293
+ inset: 0 0 auto;
294
+ min-width: 0;
295
+ transform: translate3d(0, var(--harness-virtual-offset), 0);
296
+ }
297
+
233
298
  .toolGroup {
234
299
  min-width: 0;
235
300
  }
@@ -423,7 +488,21 @@ export class DeesHarnessMessageList extends DeesElement {
423
488
 
424
489
  public willUpdate(changedProperties: Map<PropertyKey, unknown>): void {
425
490
  super.willUpdate(changedProperties);
426
- this.timelineRenderItems = this.buildTimelineRenderItems();
491
+ if (changedProperties.has('viewState') && this.viewState) {
492
+ this.clearTranscriptViewState(this.localViewState);
493
+ }
494
+ const registryChanged = this.timelineRegistryRevision !== this.toolRegistry.revision;
495
+ if (
496
+ this.timelineRegistryRevision < 0
497
+ || changedProperties.has('messages')
498
+ || changedProperties.has('permissions')
499
+ || changedProperties.has('questions')
500
+ || changedProperties.has('toolRegistry')
501
+ || changedProperties.has('viewState')
502
+ || registryChanged
503
+ ) {
504
+ this.rebuildTimeline();
505
+ }
427
506
  }
428
507
 
429
508
  private appendToolRun(
@@ -432,20 +511,231 @@ export class DeesHarnessMessageList extends DeesElement {
432
511
  layoutArg: THarnessToolLayout,
433
512
  usedGroupIdsArg: Set<string>,
434
513
  ): void {
435
- const groupId = runArg
436
- .map((item) => this.toolGroupIdByMessageId.get(item.message.id))
437
- .find((candidate): candidate is string => candidate !== undefined && !usedGroupIdsArg.has(candidate))
438
- ?? `tool-group-${this.nextToolGroupId++}`;
439
- usedGroupIdsArg.add(groupId);
440
- for (const item of runArg) {
441
- this.toolGroupIdByMessageId.set(item.message.id, groupId);
442
- }
443
- targetArg.push({
444
- kind: 'tool-group',
445
- key: groupId,
446
- layout: layoutArg,
447
- messages: runArg,
448
- });
514
+ for (let offset = 0; offset < runArg.length; offset += maximumToolMessagesPerGroup) {
515
+ const messages = runArg.slice(offset, offset + maximumToolMessagesPerGroup);
516
+ const groupId = messages
517
+ .map((item) => this.toolGroupIdByMessageId.get(item.message.id))
518
+ .find((candidate): candidate is string => candidate !== undefined && !usedGroupIdsArg.has(candidate))
519
+ ?? `tool-group-${this.nextToolGroupId++}`;
520
+ usedGroupIdsArg.add(groupId);
521
+ for (const item of messages) {
522
+ this.toolGroupIdByMessageId.set(item.message.id, groupId);
523
+ }
524
+ targetArg.push({
525
+ kind: 'tool-group',
526
+ key: groupId,
527
+ layout: layoutArg,
528
+ messages,
529
+ });
530
+ }
531
+ }
532
+
533
+ private rebuildTimeline(): void {
534
+ const container = this.scrollContainer;
535
+ const preserveAnchor = this.userUnpinned || !this.autoFollow;
536
+ const anchor = container && preserveAnchor
537
+ ? this.captureCurrentAnchor(container)
538
+ : undefined;
539
+ const nextItems = this.buildTimelineRenderItems();
540
+ 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
+ ];
546
+ this.currentNewContentKeys = new Set(
547
+ contentKeys.filter((key) => !this.knownContentKeys.has(key)),
548
+ );
549
+ this.knownContentKeys.clear();
550
+ for (const key of contentKeys) this.knownContentKeys.add(key);
551
+ for (const key of this.measuredVirtualHeights.keys()) {
552
+ if (!retainedKeys.has(key)) this.measuredVirtualHeights.delete(key);
553
+ }
554
+ if (this.explicitRetainedVirtualKey && !retainedKeys.has(this.explicitRetainedVirtualKey)) {
555
+ this.explicitRetainedVirtualKey = undefined;
556
+ }
557
+ for (const key of this.retainedVirtualKeys) {
558
+ if (!retainedKeys.has(key)) this.retainedVirtualKeys.delete(key);
559
+ }
560
+ const messageIds = new Set(this.messages.map((message) => message.id));
561
+ const permissionIds = new Set(this.permissions.map((request) => request.id));
562
+ const questionIds = new Set(this.questions.map((request) => request.id));
563
+ const effectiveViewState = this.effectiveViewState;
564
+ for (const key of Object.keys(effectiveViewState.messages)) {
565
+ if (!messageIds.has(key)) delete effectiveViewState.messages[key];
566
+ }
567
+ for (const key of Object.keys(effectiveViewState.permissions)) {
568
+ if (!permissionIds.has(key)) delete effectiveViewState.permissions[key];
569
+ }
570
+ for (const key of Object.keys(effectiveViewState.questions)) {
571
+ if (!questionIds.has(key)) delete effectiveViewState.questions[key];
572
+ }
573
+ for (const message of this.messages) effectiveViewState.messages[message.id] ??= {};
574
+ for (const request of this.permissions) effectiveViewState.permissions[request.id] ??= {};
575
+ for (const request of this.questions) effectiveViewState.questions[request.id] ??= {};
576
+ for (const message of this.messages) this.syncSubtaskViewState(message);
577
+ this.timelineRenderItems = nextItems;
578
+ this.timelineIndexByKey = new Map(nextItems.map((item, index) => [item.key, index]));
579
+ this.timelineRegistryRevision = this.toolRegistry.revision;
580
+ const shouldVirtualize = contentKeys.length > virtualizationThreshold;
581
+ if (!this.virtualizationEnabled && shouldVirtualize) {
582
+ this.virtualizationDisablePending = false;
583
+ if (this.activeTimelineKey()) {
584
+ this.virtualizationEnablePending = true;
585
+ this.connectSelectionChangeListener();
586
+ } else {
587
+ this.captureFullTimelineMeasurements();
588
+ this.virtualizationEnabled = true;
589
+ this.virtualizationEnablePending = false;
590
+ this.disconnectSelectionChangeListener();
591
+ }
592
+ } else if (this.virtualizationEnabled && !shouldVirtualize) {
593
+ this.virtualizationEnablePending = false;
594
+ if (this.activeTimelineKey()) {
595
+ this.virtualizationDisablePending = true;
596
+ this.connectSelectionChangeListener();
597
+ } else {
598
+ this.virtualizationEnabled = false;
599
+ this.virtualizationDisablePending = false;
600
+ this.retainedVirtualKeys.clear();
601
+ this.disconnectSelectionChangeListener();
602
+ }
603
+ } else {
604
+ this.virtualizationEnablePending = false;
605
+ this.virtualizationDisablePending = false;
606
+ if (this.activeSelectionKeys().length === 0) this.disconnectSelectionChangeListener();
607
+ }
608
+ this.recomputeVirtualOffsets();
609
+ if (anchor && container) {
610
+ if (this.virtualizationActive) {
611
+ this.pendingVirtualAnchor = anchor;
612
+ this.measurementAnchor = anchor;
613
+ this.setVirtualRangeAround(anchor.key);
614
+ } else {
615
+ this.pendingVirtualAnchor = undefined;
616
+ this.pendingFullAnchor = anchor;
617
+ this.setInitialVirtualRange();
618
+ }
619
+ } else {
620
+ this.measurementAnchor = undefined;
621
+ this.setInitialVirtualRange();
622
+ }
623
+ }
624
+
625
+ private pruneViewState(
626
+ stateArg: IHarnessTranscriptViewState,
627
+ messageIdsArg: string[],
628
+ permissionIdsArg: string[],
629
+ questionIdsArg: string[],
630
+ ): void {
631
+ const messageIds = new Set(messageIdsArg);
632
+ const permissionIds = new Set(permissionIdsArg);
633
+ const questionIds = new Set(questionIdsArg);
634
+ for (const key of Object.keys(stateArg.messages)) if (!messageIds.has(key)) delete stateArg.messages[key];
635
+ for (const key of Object.keys(stateArg.permissions)) if (!permissionIds.has(key)) delete stateArg.permissions[key];
636
+ for (const key of Object.keys(stateArg.questions)) if (!questionIds.has(key)) delete stateArg.questions[key];
637
+ }
638
+
639
+ private clearTranscriptViewState(stateArg: IHarnessTranscriptViewState): void {
640
+ for (const key of Object.keys(stateArg.messages)) delete stateArg.messages[key];
641
+ for (const key of Object.keys(stateArg.permissions)) delete stateArg.permissions[key];
642
+ for (const key of Object.keys(stateArg.questions)) delete stateArg.questions[key];
643
+ }
644
+
645
+ private syncSubtaskViewState(messageArg: IHarnessMessage): void {
646
+ const messageState = this.effectiveViewState.messages[messageArg.id];
647
+ if (!messageState) return;
648
+ const subtask = messageArg.toolCall?.subtask;
649
+ if (
650
+ !this.allowSubtaskStreams
651
+ || !subtask
652
+ || subtask.previewState !== 'ready'
653
+ || subtask.status.type !== 'busy'
654
+ ) {
655
+ delete messageState.subtaskTranscript;
656
+ delete messageState.subtaskTranscriptSessionId;
657
+ return;
658
+ }
659
+ if (
660
+ messageState.subtaskTranscriptSessionId !== subtask.sessionId
661
+ || !messageState.subtaskTranscript
662
+ ) {
663
+ messageState.subtaskTranscriptSessionId = subtask.sessionId;
664
+ messageState.subtaskTranscript = { messages: {}, permissions: {}, questions: {} };
665
+ }
666
+ this.pruneViewState(
667
+ messageState.subtaskTranscript,
668
+ subtask.messages.map((entry) => entry.id),
669
+ (subtask.permissions ?? []).map((entry) => entry.id),
670
+ (subtask.questions ?? []).map((entry) => entry.id),
671
+ );
672
+ }
673
+
674
+ private get virtualizationActive(): boolean {
675
+ return this.virtualizationEnabled;
676
+ }
677
+
678
+ private estimatedVirtualHeight(itemArg: TTimelineRenderItem): number {
679
+ if (itemArg.kind === 'permission') return 150;
680
+ if (itemArg.kind === 'question') return 180;
681
+ if (itemArg.kind === 'tool-group') {
682
+ if (itemArg.layout === 'subtask') return 320;
683
+ return itemArg.layout === 'full-row' ? 120 * itemArg.messages.length : 110;
684
+ }
685
+ if (itemArg.message.reasoning?.some((part) => !part.endedAt)) return 220;
686
+ return itemArg.message.text.length > 1_000 ? 180 : 96;
687
+ }
688
+
689
+ private recomputeVirtualOffsets(): void {
690
+ const offsets: number[] = [];
691
+ let offset = 0;
692
+ for (const item of this.timelineRenderItems) {
693
+ offsets.push(offset);
694
+ offset += this.measuredVirtualHeights.get(item.key) ?? this.estimatedVirtualHeight(item);
695
+ offset += virtualGapPx;
696
+ }
697
+ this.virtualOffsets = offsets;
698
+ this.virtualTotalHeight = Math.max(0, offset - virtualGapPx);
699
+ }
700
+
701
+ private setInitialVirtualRange(): void {
702
+ if (!this.virtualizationActive) {
703
+ this.virtualStartIndex = 0;
704
+ this.virtualEndIndex = this.timelineRenderItems.length;
705
+ return;
706
+ }
707
+ const initialCount = Math.min(this.timelineRenderItems.length, 24);
708
+ this.virtualEndIndex = this.autoFollow ? this.timelineRenderItems.length : initialCount;
709
+ this.virtualStartIndex = this.autoFollow ? this.virtualEndIndex - initialCount : 0;
710
+ }
711
+
712
+ private setVirtualRangeAround(keyArg: string): void {
713
+ const anchorIndex = this.timelineIndexByKey.get(keyArg);
714
+ if (anchorIndex === undefined) {
715
+ this.setInitialVirtualRange();
716
+ return;
717
+ }
718
+ this.virtualStartIndex = Math.max(0, anchorIndex - 12);
719
+ this.virtualEndIndex = Math.min(this.timelineRenderItems.length, anchorIndex + 13);
720
+ }
721
+
722
+ private captureFullTimelineMeasurements(): void {
723
+ for (const element of this.shadowRoot?.querySelectorAll<HTMLElement>('.stack > [data-timeline-key]') ?? []) {
724
+ const key = element.dataset.timelineKey;
725
+ const height = element?.getBoundingClientRect().height ?? 0;
726
+ if (key && height > 0) this.measuredVirtualHeights.set(key, height);
727
+ }
728
+ }
729
+
730
+ private captureCurrentAnchor(containerArg: HTMLElement): { key: string; offset: number } | undefined {
731
+ if (this.virtualizationActive) return this.captureVirtualAnchor(containerArg);
732
+ const containerTop = containerArg.getBoundingClientRect().top;
733
+ const itemElements = Array.from(
734
+ this.shadowRoot?.querySelectorAll<HTMLElement>('.stack > [data-timeline-key]') ?? [],
735
+ );
736
+ const element = itemElements.find((candidate) => candidate.getBoundingClientRect().bottom > containerTop);
737
+ const key = element?.dataset.timelineKey;
738
+ return key ? { key, offset: containerTop - element.getBoundingClientRect().top } : undefined;
449
739
  }
450
740
 
451
741
  public render(): TemplateResult {
@@ -456,37 +746,18 @@ export class DeesHarnessMessageList extends DeesElement {
456
746
  @scroll=${this.handleScroll}
457
747
  @wheel=${this.handleUserScrollIntent}
458
748
  @touchmove=${this.handleUserScrollIntent}
459
- @pointerdown=${this.handleUserScrollIntent}
749
+ @pointerdown=${this.handlePointerDown}
750
+ @pointerup=${this.handlePointerUp}
751
+ @pointercancel=${this.handlePointerCancel}
460
752
  @keydown=${this.handleUserScrollIntent}
753
+ @keyup=${this.handleProtectedStateChange}
754
+ @focusout=${this.handleProtectedStateChange}
755
+ @harness-card-retention=${this.handleCardRetention}
461
756
  >
462
757
  <div class="scrollContent">
463
758
  <slot name="leading"></slot>
464
759
  ${items.length
465
- ? html`
466
- <div class="stack">
467
- ${repeat(
468
- items,
469
- (item) => item.key,
470
- (item) => item.kind === 'tool-group'
471
- ? this.renderToolGroup(item)
472
- : item.kind === 'message'
473
- ? this.renderMessage(item)
474
- : item.kind === 'permission'
475
- ? html`
476
- <dees-harness-permission-card
477
- .request=${item.request}
478
- .busy=${this.permissionsBusy}
479
- ></dees-harness-permission-card>
480
- `
481
- : html`
482
- <dees-harness-question-card
483
- .request=${item.question}
484
- .busy=${this.permissionsBusy}
485
- ></dees-harness-question-card>
486
- `,
487
- )}
488
- </div>
489
- `
760
+ ? this.virtualizationActive ? this.renderVirtualTimeline() : this.renderFullTimeline(items)
490
761
  : html`
491
762
  <div class="empty">
492
763
  <slot name="empty">
@@ -511,9 +782,94 @@ export class DeesHarnessMessageList extends DeesElement {
511
782
  `;
512
783
  }
513
784
 
514
- private renderMessage(itemArg: TMessageTimelineItem): TemplateResult {
785
+ private renderFullTimeline(itemsArg: TTimelineRenderItem[]): TemplateResult {
786
+ return html`
787
+ <div class="stack" role="log" aria-label="Conversation transcript">
788
+ ${repeat(
789
+ itemsArg,
790
+ (item) => item.key,
791
+ (item, index) => this.renderTimelineItem(item, index),
792
+ )}
793
+ </div>
794
+ `;
795
+ }
796
+
797
+ private renderVirtualTimeline(): TemplateResult {
798
+ const indexedItems = this.virtualRenderedIndexes().map((index) => ({
799
+ index,
800
+ item: this.timelineRenderItems[index]!,
801
+ }));
802
+ return html`
803
+ <div
804
+ class="virtualStack"
805
+ role="log"
806
+ aria-label="Conversation transcript"
807
+ style=${`height: ${this.virtualTotalHeight}px;`}
808
+ >
809
+ ${repeat(
810
+ indexedItems,
811
+ (entry) => entry.item.key,
812
+ (entry) => html`
813
+ <article
814
+ class="virtualItem"
815
+ data-virtual-key=${entry.item.key}
816
+ aria-posinset=${entry.index + 1}
817
+ aria-setsize=${this.timelineRenderItems.length}
818
+ style=${`--harness-virtual-offset: ${this.virtualOffsets[entry.index] ?? 0}px;`}
819
+ >
820
+ ${this.renderTimelineItem(entry.item, entry.index)}
821
+ </article>
822
+ `,
823
+ )}
824
+ </div>
825
+ `;
826
+ }
827
+
828
+ private virtualRenderedIndexes(): number[] {
829
+ const indexes: number[] = [];
830
+ for (let index = this.virtualStartIndex; index < this.virtualEndIndex; index += 1) {
831
+ indexes.push(index);
832
+ }
833
+ for (const retainedKey of this.retainedVirtualKeys) {
834
+ const retainedIndex = this.timelineIndexByKey.get(retainedKey);
835
+ if (retainedIndex !== undefined && !indexes.includes(retainedIndex)) indexes.push(retainedIndex);
836
+ }
837
+ return indexes.sort((left, right) => left - right);
838
+ }
839
+
840
+ private renderTimelineItem(itemArg: TTimelineRenderItem, logicalIndexArg: number): TemplateResult {
841
+ if (itemArg.kind === 'tool-group') return this.renderToolGroup(itemArg);
842
+ if (itemArg.kind === 'message') return this.renderMessage(itemArg, itemArg.key);
843
+ if (itemArg.kind === 'permission') {
844
+ return html`
845
+ <dees-harness-permission-card
846
+ data-timeline-key=${itemArg.key}
847
+ aria-posinset=${logicalIndexArg + 1}
848
+ aria-setsize=${this.timelineRenderItems.length}
849
+ .request=${itemArg.request}
850
+ .busy=${this.permissionsBusy}
851
+ .viewState=${this.permissionViewState(itemArg.request.id)}
852
+ .suppressEntry=${!this.entryAnimationsEnabled || !this.currentNewContentKeys.has(itemArg.key)}
853
+ ></dees-harness-permission-card>
854
+ `;
855
+ }
856
+ return html`
857
+ <dees-harness-question-card
858
+ data-timeline-key=${itemArg.key}
859
+ aria-posinset=${logicalIndexArg + 1}
860
+ aria-setsize=${this.timelineRenderItems.length}
861
+ .request=${itemArg.question}
862
+ .busy=${this.permissionsBusy}
863
+ .viewState=${this.questionViewState(itemArg.question.id)}
864
+ .suppressEntry=${!this.entryAnimationsEnabled || !this.currentNewContentKeys.has(itemArg.key)}
865
+ ></dees-harness-question-card>
866
+ `;
867
+ }
868
+
869
+ private renderMessage(itemArg: TMessageTimelineItem, timelineKeyArg: string): TemplateResult {
515
870
  return html`
516
871
  <dees-harness-message
872
+ data-timeline-key=${timelineKeyArg}
517
873
  data-message-id=${itemArg.message.id}
518
874
  data-tool-layout=${itemArg.toolLayout ?? 'message'}
519
875
  .message=${itemArg.message}
@@ -521,17 +877,35 @@ export class DeesHarnessMessageList extends DeesElement {
521
877
  .showTimestamp=${this.showTimestamps}
522
878
  .toolRegistry=${this.toolRegistry}
523
879
  .allowSubtaskStreams=${this.allowSubtaskStreams}
524
- .suppressEntry=${!this.entryAnimationsEnabled}
880
+ .viewState=${this.messageViewState(itemArg.message.id)}
881
+ .suppressEntry=${!this.entryAnimationsEnabled || !this.currentNewContentKeys.has(itemArg.key)}
525
882
  ></dees-harness-message>
526
883
  `;
527
884
  }
528
885
 
886
+ private messageViewState(messageIdArg: string): IHarnessMessageViewState {
887
+ return this.effectiveViewState.messages[messageIdArg]!;
888
+ }
889
+
890
+ private permissionViewState(requestIdArg: string): IHarnessPermissionViewState {
891
+ return this.effectiveViewState.permissions[requestIdArg]!;
892
+ }
893
+
894
+ private questionViewState(requestIdArg: string): IHarnessQuestionViewState {
895
+ return this.effectiveViewState.questions[requestIdArg]!;
896
+ }
897
+
898
+ private get effectiveViewState(): IHarnessTranscriptViewState {
899
+ return this.viewState ?? this.localViewState;
900
+ }
901
+
529
902
  private renderToolGroup(groupArg: IToolGroupTimelineItem): TemplateResult {
530
903
  const count = groupArg.messages.length;
531
904
  const countClass = count >= 4 ? 'toolCount4' : `toolCount${count}`;
532
905
  return html`
533
906
  <section
534
907
  class="toolGroup"
908
+ data-timeline-key=${groupArg.key}
535
909
  data-tool-group-id=${groupArg.key}
536
910
  data-tool-layout=${groupArg.layout}
537
911
  >
@@ -539,7 +913,7 @@ export class DeesHarnessMessageList extends DeesElement {
539
913
  ${repeat(
540
914
  groupArg.messages,
541
915
  (item) => item.key,
542
- (item) => this.renderMessage(item),
916
+ (item) => this.renderMessage(item, groupArg.key),
543
917
  )}
544
918
  </div>
545
919
  </section>
@@ -591,9 +965,15 @@ export class DeesHarnessMessageList extends DeesElement {
591
965
  else harnessApplyMessageDelta(this.messages, delta);
592
966
  break;
593
967
  case 'tool-update':
968
+ const previousLayout = message.toolCall ? this.toolRegistry.resolveLayout(message.toolCall) : null;
594
969
  message.toolCall = { ...(message.toolCall ?? { id: delta.tool.id, name: '', status: 'pending' }), ...delta.tool };
970
+ const nextLayout = this.toolRegistry.resolveLayout(message.toolCall);
971
+ this.syncSubtaskViewState(message);
595
972
  element?.requestUpdate();
596
- this.requestUpdate();
973
+ if (previousLayout !== nextLayout) {
974
+ this.rebuildTimeline();
975
+ this.requestUpdate();
976
+ }
597
977
  break;
598
978
  case 'message-end':
599
979
  if (element) element.endStream(delta.usage, delta.error);
@@ -676,6 +1056,7 @@ export class DeesHarnessMessageList extends DeesElement {
676
1056
  ) return;
677
1057
  if (event.type === 'pointerdown' && event.target !== this.scrollContainer) return;
678
1058
  this.userUnpinned = true;
1059
+ this.measurementAnchor = undefined;
679
1060
  };
680
1061
 
681
1062
  public scrollToBottom(force = false): void {
@@ -683,6 +1064,7 @@ export class DeesHarnessMessageList extends DeesElement {
683
1064
  if (!container) return;
684
1065
  if (force) {
685
1066
  this.userUnpinned = false;
1067
+ this.measurementAnchor = undefined;
686
1068
  }
687
1069
  if (force || this.isPinnedToBottom) {
688
1070
  container.scrollTop = container.scrollHeight;
@@ -705,8 +1087,113 @@ export class DeesHarnessMessageList extends DeesElement {
705
1087
  }
706
1088
  }
707
1089
  this.lastObservedScrollTop = top;
1090
+ this.scheduleVirtualRangeUpdate();
1091
+ this.schedulePendingVirtualizationActivation();
1092
+ };
1093
+
1094
+ private schedulePendingVirtualizationActivation(): void {
1095
+ if (
1096
+ (!this.virtualizationEnablePending && !this.virtualizationDisablePending)
1097
+ || this.virtualizationActivationFrame !== null
1098
+ ) return;
1099
+ this.virtualizationActivationFrame = requestAnimationFrame(() => {
1100
+ this.virtualizationActivationFrame = null;
1101
+ if (!this.isConnected || this.activeTimelineKey()) return;
1102
+ const container = this.scrollContainer;
1103
+ const anchor = container ? this.captureCurrentAnchor(container) : undefined;
1104
+ if (this.virtualizationEnablePending) {
1105
+ this.captureFullTimelineMeasurements();
1106
+ this.virtualizationEnabled = true;
1107
+ this.virtualizationEnablePending = false;
1108
+ this.recomputeVirtualOffsets();
1109
+ if (anchor) {
1110
+ this.pendingVirtualAnchor = anchor;
1111
+ this.setVirtualRangeAround(anchor.key);
1112
+ } else {
1113
+ this.setInitialVirtualRange();
1114
+ }
1115
+ } else if (this.virtualizationDisablePending) {
1116
+ this.virtualizationEnabled = false;
1117
+ this.virtualizationDisablePending = false;
1118
+ this.retainedVirtualKeys.clear();
1119
+ if (anchor) this.pendingFullAnchor = anchor;
1120
+ this.setInitialVirtualRange();
1121
+ }
1122
+ this.disconnectSelectionChangeListener();
1123
+ this.requestUpdate();
1124
+ });
1125
+ }
1126
+
1127
+ private readonly handleSelectionChange = (): void => {
1128
+ const selection = document.getSelection();
1129
+ if (
1130
+ !selection
1131
+ || selection.rangeCount === 0
1132
+ || (
1133
+ !this.selectionGestureActive
1134
+ && this.selectionGestureSnapshot
1135
+ && !this.selectionMatchesGestureSnapshot(selection)
1136
+ )
1137
+ || (
1138
+ !this.selectionGestureActive
1139
+ && selection.isCollapsed
1140
+ && !this.hasCrossShadowSelectionFallback
1141
+ )
1142
+ ) this.clearSelectionGesture();
1143
+ this.scheduleVirtualRangeUpdate();
1144
+ this.schedulePendingVirtualizationActivation();
1145
+ };
1146
+
1147
+ private readonly handlePointerDown = (eventArg: PointerEvent): void => {
1148
+ this.handleUserScrollIntent(eventArg);
1149
+ if (eventArg.button !== 0) return;
1150
+ this.clearSelectionGesture();
1151
+ this.selectionGestureActive = true;
1152
+ this.selectionGestureStartOwner = this.selectionOwnerForEvent(eventArg);
1153
+ const key = this.timelineKeyForEvent(eventArg);
1154
+ if (key) this.selectionGestureKeys.add(key);
1155
+ };
1156
+
1157
+ private readonly handlePointerUp = (eventArg: PointerEvent): void => {
1158
+ if (eventArg.button !== 0) return;
1159
+ const key = this.timelineKeyForEvent(eventArg);
1160
+ if (key) this.selectionGestureKeys.add(key);
1161
+ const endOwner = this.selectionOwnerForEvent(eventArg);
1162
+ this.selectionGestureCrossesOwners = Boolean(
1163
+ this.selectionGestureStartOwner
1164
+ && endOwner
1165
+ && this.selectionGestureStartOwner !== endOwner,
1166
+ );
1167
+ this.selectionGestureActive = false;
1168
+ this.selectionGestureSnapshot = this.captureSelectionSnapshot(document.getSelection());
1169
+ if (!this.hasCrossShadowSelectionFallback && document.getSelection()?.isCollapsed !== false) {
1170
+ this.clearSelectionGesture();
1171
+ }
1172
+ this.handleProtectedStateChange();
708
1173
  };
709
1174
 
1175
+ private readonly handlePointerCancel = (): void => {
1176
+ this.clearSelectionGesture();
1177
+ this.handleProtectedStateChange();
1178
+ };
1179
+
1180
+ private readonly handleProtectedStateChange = (): void => {
1181
+ this.scheduleVirtualRangeUpdate();
1182
+ this.schedulePendingVirtualizationActivation();
1183
+ };
1184
+
1185
+ private connectSelectionChangeListener(): void {
1186
+ if (this.selectionChangeListening) return;
1187
+ document.addEventListener('selectionchange', this.handleSelectionChange);
1188
+ this.selectionChangeListening = true;
1189
+ }
1190
+
1191
+ private disconnectSelectionChangeListener(): void {
1192
+ if (!this.selectionChangeListening) return;
1193
+ document.removeEventListener('selectionchange', this.handleSelectionChange);
1194
+ this.selectionChangeListening = false;
1195
+ }
1196
+
710
1197
  public updated(changedProperties: Map<string, any>): void {
711
1198
  super.updated(changedProperties);
712
1199
  if (changedProperties.has('messages') || changedProperties.has('status') || changedProperties.has('permissions') || changedProperties.has('questions')) {
@@ -725,6 +1212,23 @@ export class DeesHarnessMessageList extends DeesElement {
725
1212
  || changedProperties.has('permissions')
726
1213
  || changedProperties.has('questions')
727
1214
  ) this.syncScrollEdgeFades();
1215
+ if (this.virtualizationActive) {
1216
+ this.connectVirtualObservers();
1217
+ if (this.pendingVirtualAnchor) {
1218
+ this.restoreVirtualAnchor(this.scrollContainer!, this.pendingVirtualAnchor);
1219
+ this.pendingVirtualAnchor = undefined;
1220
+ } else if (this.pendingVirtualRepin) {
1221
+ this.repinToBottom();
1222
+ this.pendingVirtualRepin = false;
1223
+ }
1224
+ this.scheduleVirtualRangeUpdate();
1225
+ } else {
1226
+ this.disconnectVirtualObservers();
1227
+ if (this.pendingFullAnchor) {
1228
+ this.restoreFullAnchor(this.scrollContainer!, this.pendingFullAnchor);
1229
+ this.pendingFullAnchor = undefined;
1230
+ }
1231
+ }
728
1232
  }
729
1233
 
730
1234
  public firstUpdated(changedProperties: Map<string, any>): void {
@@ -733,6 +1237,7 @@ export class DeesHarnessMessageList extends DeesElement {
733
1237
  this.connectContentResizeObserver();
734
1238
  this.repinToBottom();
735
1239
  this.syncScrollEdgeFades();
1240
+ if (this.virtualizationActive) this.connectVirtualObservers();
736
1241
  // let the initial batch paint without entry animations, then enable them
737
1242
  this.entryFrame = requestAnimationFrame(() => {
738
1243
  this.entryFrame = null;
@@ -747,7 +1252,14 @@ export class DeesHarnessMessageList extends DeesElement {
747
1252
 
748
1253
  public async connectedCallback(): Promise<void> {
749
1254
  const connected = super.connectedCallback();
750
- if (this.hasUpdated) this.connectContentResizeObserver();
1255
+ if (this.virtualizationEnablePending || this.virtualizationDisablePending) {
1256
+ this.connectSelectionChangeListener();
1257
+ }
1258
+ if (this.hasUpdated) {
1259
+ this.connectContentResizeObserver();
1260
+ if (this.virtualizationActive) this.connectVirtualObservers();
1261
+ this.schedulePendingVirtualizationActivation();
1262
+ }
751
1263
  await connected;
752
1264
  }
753
1265
 
@@ -767,6 +1279,369 @@ export class DeesHarnessMessageList extends DeesElement {
767
1279
  this.observedScrollContent = content;
768
1280
  }
769
1281
 
1282
+ private connectVirtualObservers(): void {
1283
+ const container = this.scrollContainer;
1284
+ if (!container) return;
1285
+ this.virtualViewportResizeObserver ??= new ResizeObserver((entries) => {
1286
+ const width = entries[0]?.contentRect.width ?? 0;
1287
+ if (width > 0 && Math.abs(width - this.lastVirtualViewportWidth) > 0.5) {
1288
+ const preserveAnchor = this.userUnpinned || !this.autoFollow;
1289
+ const anchor = preserveAnchor ? this.captureVirtualMetricAnchor(container) : undefined;
1290
+ this.lastVirtualViewportWidth = width;
1291
+ this.measuredVirtualHeights.clear();
1292
+ this.recomputeVirtualOffsets();
1293
+ if (anchor) this.pendingVirtualAnchor = anchor;
1294
+ else this.pendingVirtualRepin = true;
1295
+ this.requestUpdate();
1296
+ this.scheduleVirtualRangeUpdate(true);
1297
+ }
1298
+ });
1299
+ this.virtualViewportResizeObserver.disconnect();
1300
+ this.virtualViewportResizeObserver.observe(container);
1301
+ this.lastVirtualViewportWidth ||= container.getBoundingClientRect().width;
1302
+
1303
+ this.virtualRowResizeObserver ??= new ResizeObserver(() => this.scheduleVirtualMeasurements());
1304
+ this.virtualRowResizeObserver.disconnect();
1305
+ for (const row of this.shadowRoot?.querySelectorAll<HTMLElement>('.virtualItem') ?? []) {
1306
+ this.virtualRowResizeObserver.observe(row);
1307
+ }
1308
+ }
1309
+
1310
+ private scheduleVirtualMeasurements(): void {
1311
+ if (this.virtualMeasureFrame !== null) return;
1312
+ this.virtualMeasureFrame = requestAnimationFrame(() => {
1313
+ this.virtualMeasureFrame = null;
1314
+ this.measureVirtualRows();
1315
+ });
1316
+ }
1317
+
1318
+ private measureVirtualRows(): void {
1319
+ const container = this.scrollContainer;
1320
+ if (!container || !this.virtualizationActive) return;
1321
+ const preserveAnchor = this.userUnpinned || !this.autoFollow;
1322
+ const anchor = preserveAnchor
1323
+ ? (this.measurementAnchor ?? this.captureVirtualMetricAnchor(container))
1324
+ : undefined;
1325
+ let changed = false;
1326
+ for (const row of this.shadowRoot?.querySelectorAll<HTMLElement>('.virtualItem') ?? []) {
1327
+ const key = row.dataset.virtualKey;
1328
+ if (!key) continue;
1329
+ const height = row.getBoundingClientRect().height;
1330
+ if (height <= 0 || Math.abs((this.measuredVirtualHeights.get(key) ?? 0) - height) <= 0.5) continue;
1331
+ this.measuredVirtualHeights.set(key, height);
1332
+ changed = true;
1333
+ }
1334
+ if (!changed) {
1335
+ this.measurementAnchor = undefined;
1336
+ return;
1337
+ }
1338
+ this.recomputeVirtualOffsets();
1339
+ if (anchor) this.pendingVirtualAnchor = anchor;
1340
+ else this.pendingVirtualRepin = true;
1341
+ this.requestUpdate();
1342
+ if (this.measurementAnchor) this.scheduleVirtualMeasurements();
1343
+ }
1344
+
1345
+ private captureVirtualAnchor(containerArg: HTMLElement): { key: string; offset: number } | undefined {
1346
+ const containerTop = containerArg.getBoundingClientRect().top;
1347
+ const row = Array.from(this.shadowRoot?.querySelectorAll<HTMLElement>('.virtualItem') ?? [])
1348
+ .find((candidate) => candidate.getBoundingClientRect().bottom > containerTop);
1349
+ const key = row?.dataset.virtualKey;
1350
+ if (row && key) return { key, offset: containerTop - row.getBoundingClientRect().top };
1351
+ const localTop = this.virtualLocalScrollTop(containerArg);
1352
+ return this.captureVirtualOffsetAnchorAt(localTop);
1353
+ }
1354
+
1355
+ private captureVirtualMetricAnchor(
1356
+ containerArg: HTMLElement,
1357
+ ): { key: string; offset: number } | undefined {
1358
+ const localTop = this.virtualLocalScrollTop(containerArg);
1359
+ for (const row of this.shadowRoot?.querySelectorAll<HTMLElement>('.virtualItem') ?? []) {
1360
+ const key = row.dataset.virtualKey;
1361
+ const index = key ? this.timelineIndexByKey.get(key) : undefined;
1362
+ if (index === undefined) continue;
1363
+ const item = this.timelineRenderItems[index]!;
1364
+ const offset = this.virtualOffsets[index] ?? 0;
1365
+ const height = this.measuredVirtualHeights.get(item.key) ?? this.estimatedVirtualHeight(item);
1366
+ if (offset + height > localTop) return { key: item.key, offset: localTop - offset };
1367
+ }
1368
+ return this.captureVirtualOffsetAnchorAt(localTop);
1369
+ }
1370
+
1371
+ private captureVirtualOffsetAnchorAt(localTopArg: number): { key: string; offset: number } | undefined {
1372
+ let index = this.virtualIndexAtOffset(localTopArg);
1373
+ const item = this.timelineRenderItems[index];
1374
+ const itemHeight = item
1375
+ ? (this.measuredVirtualHeights.get(item.key) ?? this.estimatedVirtualHeight(item))
1376
+ : 0;
1377
+ if (
1378
+ item
1379
+ && localTopArg > (this.virtualOffsets[index] ?? 0) + itemHeight
1380
+ && index + 1 < this.timelineRenderItems.length
1381
+ ) index += 1;
1382
+ const anchorItem = this.timelineRenderItems[index];
1383
+ return anchorItem
1384
+ ? { key: anchorItem.key, offset: localTopArg - (this.virtualOffsets[index] ?? 0) }
1385
+ : undefined;
1386
+ }
1387
+
1388
+ private restoreVirtualAnchor(
1389
+ containerArg: HTMLElement,
1390
+ anchorArg: { key: string; offset: number },
1391
+ ): void {
1392
+ const index = this.timelineIndexByKey.get(anchorArg.key);
1393
+ if (index === undefined) return;
1394
+ const stack = this.shadowRoot?.querySelector<HTMLElement>('.virtualStack');
1395
+ containerArg.scrollTop = (stack?.offsetTop ?? 0) + (this.virtualOffsets[index] ?? 0) + anchorArg.offset;
1396
+ }
1397
+
1398
+ private restoreFullAnchor(
1399
+ containerArg: HTMLElement,
1400
+ anchorArg: { key: string; offset: number },
1401
+ ): void {
1402
+ const element = this.shadowRoot?.querySelector<HTMLElement>(
1403
+ `.stack > [data-timeline-key="${CSS.escape(anchorArg.key)}"]`,
1404
+ );
1405
+ if (!element) return;
1406
+ containerArg.scrollTop += element.getBoundingClientRect().top
1407
+ - containerArg.getBoundingClientRect().top
1408
+ + anchorArg.offset;
1409
+ }
1410
+
1411
+ private virtualLocalScrollTop(containerArg: HTMLElement): number {
1412
+ const stack = this.shadowRoot?.querySelector<HTMLElement>('.virtualStack');
1413
+ return Math.max(0, containerArg.scrollTop - (stack?.offsetTop ?? 0));
1414
+ }
1415
+
1416
+ private virtualIndexAtOffset(offsetArg: number, offsetsArg = this.virtualOffsets): number {
1417
+ if (offsetsArg.length === 0) return 0;
1418
+ let low = 0;
1419
+ let high = offsetsArg.length - 1;
1420
+ while (low < high) {
1421
+ const middle = Math.floor((low + high + 1) / 2);
1422
+ if ((offsetsArg[middle] ?? 0) <= offsetArg) low = middle;
1423
+ else high = middle - 1;
1424
+ }
1425
+ return low;
1426
+ }
1427
+
1428
+ private scheduleVirtualRangeUpdate(force = false): void {
1429
+ if (!this.virtualizationActive) return;
1430
+ if (force && this.virtualRangeFrame !== null) {
1431
+ cancelAnimationFrame(this.virtualRangeFrame);
1432
+ this.virtualRangeFrame = null;
1433
+ }
1434
+ if (this.virtualRangeFrame !== null) return;
1435
+ this.virtualRangeFrame = requestAnimationFrame(() => {
1436
+ this.virtualRangeFrame = null;
1437
+ this.updateVirtualRange();
1438
+ });
1439
+ }
1440
+
1441
+ private updateVirtualRange(): void {
1442
+ const container = this.scrollContainer;
1443
+ if (!container || !this.virtualizationActive) return;
1444
+ const localTop = this.virtualLocalScrollTop(container);
1445
+ const startTarget = Math.max(0, localTop - virtualOverscanPx);
1446
+ const endTarget = localTop + container.clientHeight + virtualOverscanPx;
1447
+ const start = Math.max(0, this.virtualIndexAtOffset(startTarget) - 1);
1448
+ const end = Math.min(this.timelineRenderItems.length, this.virtualIndexAtOffset(endTarget) + 2);
1449
+ const retainedKeys = new Set(this.activeVirtualKeys());
1450
+ if (
1451
+ start === this.virtualStartIndex
1452
+ && end === this.virtualEndIndex
1453
+ && retainedKeys.size === this.retainedVirtualKeys.size
1454
+ && [...retainedKeys].every((key) => this.retainedVirtualKeys.has(key))
1455
+ ) return;
1456
+ this.virtualStartIndex = start;
1457
+ this.virtualEndIndex = end;
1458
+ this.retainedVirtualKeys = retainedKeys;
1459
+ if (this.activeSelectionKeys().length > 0) this.connectSelectionChangeListener();
1460
+ else if (!this.virtualizationEnablePending && !this.virtualizationDisablePending) {
1461
+ this.disconnectSelectionChangeListener();
1462
+ }
1463
+ this.requestUpdate();
1464
+ }
1465
+
1466
+ private activeVirtualKeys(): string[] {
1467
+ const focusKey = this.activeFocusKey();
1468
+ return [
1469
+ ...(this.explicitRetainedVirtualKey ? [this.explicitRetainedVirtualKey] : []),
1470
+ ...(focusKey ? [focusKey] : []),
1471
+ ...this.activeSelectionKeys(),
1472
+ ].filter((key, index, keys) => keys.indexOf(key) === index);
1473
+ }
1474
+
1475
+ private activeTimelineKey(): string | undefined {
1476
+ if (this.explicitRetainedVirtualKey) return this.explicitRetainedVirtualKey;
1477
+ return this.activeFocusKey() ?? this.activeSelectionKeys()[0];
1478
+ }
1479
+
1480
+ private activeFocusKey(): string | undefined {
1481
+ const activeElement = this.shadowRoot?.activeElement;
1482
+ if (activeElement instanceof Element) {
1483
+ const focusedKey = this.ownedTimelineElement(activeElement)?.dataset.timelineKey
1484
+ ?? this.ownedVirtualRow(activeElement)?.dataset.virtualKey;
1485
+ if (focusedKey) return focusedKey;
1486
+ }
1487
+ return undefined;
1488
+ }
1489
+
1490
+ private activeSelectionKeys(): string[] {
1491
+ if (this.selectionGestureActive) return [...this.selectionGestureKeys];
1492
+ const selection = document.getSelection();
1493
+ if (!selection || selection.rangeCount === 0) return [];
1494
+ if (selection.isCollapsed) {
1495
+ return this.hasCrossShadowSelectionFallback ? [...this.selectionGestureKeys] : [];
1496
+ }
1497
+ const range = selection.getRangeAt(0);
1498
+ const nodes: Array<Node | null> = [
1499
+ selection.anchorNode,
1500
+ selection.focusNode,
1501
+ range.startContainer,
1502
+ range.endContainer,
1503
+ ];
1504
+ if ('getComposedRanges' in selection && typeof selection.getComposedRanges === 'function') {
1505
+ const composedRanges = selection.getComposedRanges({
1506
+ shadowRoots: this.mountedShadowRoots(),
1507
+ });
1508
+ const composedRange = composedRanges[0];
1509
+ if (composedRange) nodes.push(composedRange.startContainer, composedRange.endContainer);
1510
+ }
1511
+ return [
1512
+ ...nodes
1513
+ .filter((node): node is Node => node !== null)
1514
+ .map((node) => this.timelineKeyForNode(node))
1515
+ .filter((key): key is string => key !== undefined),
1516
+ ...this.selectionGestureKeys,
1517
+ ]
1518
+ .filter((key, index, keys) => keys.indexOf(key) === index);
1519
+ }
1520
+
1521
+ private mountedShadowRoots(): ShadowRoot[] {
1522
+ const roots: ShadowRoot[] = [];
1523
+ const collect = (rootArg: ShadowRoot): void => {
1524
+ for (const element of rootArg.querySelectorAll<HTMLElement>('*')) {
1525
+ if (!element.shadowRoot) continue;
1526
+ roots.push(element.shadowRoot);
1527
+ collect(element.shadowRoot);
1528
+ }
1529
+ };
1530
+ if (this.shadowRoot) collect(this.shadowRoot);
1531
+ return roots;
1532
+ }
1533
+
1534
+ private timelineKeyForNode(nodeArg: Node): string | undefined {
1535
+ let node: Node | null = nodeArg;
1536
+ while (node) {
1537
+ if (node instanceof HTMLElement && node.classList.contains('virtualItem')) {
1538
+ const ownedRow = this.ownedVirtualRow(node);
1539
+ if (ownedRow) return ownedRow.dataset.virtualKey;
1540
+ }
1541
+ if (node instanceof HTMLElement) {
1542
+ const timelineElement = this.ownedTimelineElement(node);
1543
+ if (timelineElement) return timelineElement.dataset.timelineKey;
1544
+ }
1545
+ const root = node.getRootNode();
1546
+ node = node.parentNode ?? (root instanceof ShadowRoot ? root.host : null);
1547
+ }
1548
+ return undefined;
1549
+ }
1550
+
1551
+ private timelineKeyForEvent(eventArg: Event): string | undefined {
1552
+ for (const candidate of eventArg.composedPath()) {
1553
+ if (!(candidate instanceof Element)) continue;
1554
+ const rowKey = this.ownedVirtualRow(candidate)?.dataset.virtualKey;
1555
+ if (rowKey) return rowKey;
1556
+ const timelineKey = this.ownedTimelineElement(candidate)?.dataset.timelineKey;
1557
+ if (timelineKey) return timelineKey;
1558
+ }
1559
+ return undefined;
1560
+ }
1561
+
1562
+ private selectionOwnerForEvent(eventArg: Event): Element | undefined {
1563
+ for (const candidate of eventArg.composedPath()) {
1564
+ if (!(candidate instanceof HTMLElement)) continue;
1565
+ if (
1566
+ (
1567
+ candidate.tagName === 'DEES-HARNESS-MESSAGE'
1568
+ || candidate.tagName === 'DEES-HARNESS-PERMISSION-CARD'
1569
+ || candidate.tagName === 'DEES-HARNESS-QUESTION-CARD'
1570
+ )
1571
+ && this.timelineKeyForNode(candidate)
1572
+ ) return candidate;
1573
+ }
1574
+ return eventArg.composedPath()
1575
+ .filter((candidate): candidate is Element => candidate instanceof Element)
1576
+ .map((candidate) => this.ownedTimelineElement(candidate) ?? this.ownedVirtualRow(candidate))
1577
+ .find((candidate): candidate is HTMLElement => candidate !== undefined);
1578
+ }
1579
+
1580
+ private get hasCrossShadowSelectionFallback(): boolean {
1581
+ return this.selectionGestureKeys.size > 1 || this.selectionGestureCrossesOwners;
1582
+ }
1583
+
1584
+ private captureSelectionSnapshot(selectionArg: Selection | null): typeof this.selectionGestureSnapshot {
1585
+ if (!selectionArg) return undefined;
1586
+ return {
1587
+ anchorNode: selectionArg.anchorNode,
1588
+ anchorOffset: selectionArg.anchorOffset,
1589
+ focusNode: selectionArg.focusNode,
1590
+ focusOffset: selectionArg.focusOffset,
1591
+ rangeCount: selectionArg.rangeCount,
1592
+ };
1593
+ }
1594
+
1595
+ private selectionMatchesGestureSnapshot(selectionArg: Selection): boolean {
1596
+ const snapshot = this.selectionGestureSnapshot;
1597
+ return Boolean(
1598
+ snapshot
1599
+ && snapshot.anchorNode === selectionArg.anchorNode
1600
+ && snapshot.anchorOffset === selectionArg.anchorOffset
1601
+ && snapshot.focusNode === selectionArg.focusNode
1602
+ && snapshot.focusOffset === selectionArg.focusOffset
1603
+ && snapshot.rangeCount === selectionArg.rangeCount,
1604
+ );
1605
+ }
1606
+
1607
+ private clearSelectionGesture(): void {
1608
+ this.selectionGestureKeys.clear();
1609
+ this.selectionGestureActive = false;
1610
+ this.selectionGestureStartOwner = undefined;
1611
+ this.selectionGestureCrossesOwners = false;
1612
+ this.selectionGestureSnapshot = undefined;
1613
+ }
1614
+
1615
+ private readonly handleCardRetention = (eventArg: CustomEvent<{ active?: boolean }>): void => {
1616
+ const pathElements = eventArg.composedPath()
1617
+ .filter((candidate): candidate is HTMLElement => candidate instanceof HTMLElement);
1618
+ const row = pathElements.map((candidate) => this.ownedVirtualRow(candidate))
1619
+ .find((candidate): candidate is HTMLElement => candidate !== undefined);
1620
+ const timelineElement = pathElements.map((candidate) => this.ownedTimelineElement(candidate))
1621
+ .find((candidate): candidate is HTMLElement => candidate !== undefined);
1622
+ const key = row?.dataset.virtualKey ?? timelineElement?.dataset.timelineKey;
1623
+ if (eventArg.detail?.active === true && key) this.explicitRetainedVirtualKey = key;
1624
+ else if (!key || this.explicitRetainedVirtualKey === key) this.explicitRetainedVirtualKey = undefined;
1625
+ this.retainedVirtualKeys = new Set(this.activeVirtualKeys());
1626
+ this.scheduleVirtualRangeUpdate();
1627
+ this.schedulePendingVirtualizationActivation();
1628
+ };
1629
+
1630
+ private ownedVirtualRow(elementArg: Element): HTMLElement | undefined {
1631
+ const row = elementArg.closest<HTMLElement>('.virtualItem');
1632
+ return row?.getRootNode() === this.shadowRoot ? row : undefined;
1633
+ }
1634
+
1635
+ private ownedTimelineElement(elementArg: Element): HTMLElement | undefined {
1636
+ const timelineElement = elementArg.closest<HTMLElement>('[data-timeline-key]');
1637
+ return timelineElement?.getRootNode() === this.shadowRoot ? timelineElement : undefined;
1638
+ }
1639
+
1640
+ private disconnectVirtualObservers(): void {
1641
+ this.virtualRowResizeObserver?.disconnect();
1642
+ this.virtualViewportResizeObserver?.disconnect();
1643
+ }
1644
+
770
1645
  private repinAfterContentGrowth(): void {
771
1646
  const content = this.observedScrollContent;
772
1647
  if (!content) return;
@@ -804,16 +1679,33 @@ export class DeesHarnessMessageList extends DeesElement {
804
1679
  }
805
1680
 
806
1681
  public async disconnectedCallback(): Promise<void> {
1682
+ this.disconnectSelectionChangeListener();
807
1683
  if (this.followFrame !== null) cancelAnimationFrame(this.followFrame);
808
1684
  if (this.followSecondFrame !== null) cancelAnimationFrame(this.followSecondFrame);
809
1685
  if (this.entryFrame !== null) cancelAnimationFrame(this.entryFrame);
810
1686
  if (this.entrySecondFrame !== null) cancelAnimationFrame(this.entrySecondFrame);
1687
+ if (this.virtualMeasureFrame !== null) cancelAnimationFrame(this.virtualMeasureFrame);
1688
+ if (this.virtualRangeFrame !== null) cancelAnimationFrame(this.virtualRangeFrame);
1689
+ if (this.virtualizationActivationFrame !== null) cancelAnimationFrame(this.virtualizationActivationFrame);
811
1690
  this.followFrame = null;
812
1691
  this.followSecondFrame = null;
813
1692
  this.entryFrame = null;
814
1693
  this.entrySecondFrame = null;
1694
+ this.virtualMeasureFrame = null;
1695
+ this.virtualRangeFrame = null;
1696
+ this.virtualizationActivationFrame = null;
1697
+ this.pendingVirtualAnchor = undefined;
1698
+ this.pendingFullAnchor = undefined;
1699
+ this.measurementAnchor = undefined;
1700
+ this.pendingVirtualRepin = false;
1701
+ this.explicitRetainedVirtualKey = undefined;
1702
+ this.retainedVirtualKeys.clear();
1703
+ this.clearSelectionGesture();
815
1704
  this.entryAnimationsEnabled = true;
816
1705
  this.disconnectContentResizeObserver();
1706
+ this.disconnectVirtualObservers();
1707
+ this.virtualRowResizeObserver = null;
1708
+ this.virtualViewportResizeObserver = null;
817
1709
  await super.disconnectedCallback();
818
1710
  }
819
1711
  }