@dxos/react-ui-feed 0.11.1
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.
- package/LICENSE +105 -0
- package/dist/lib/chunk-src.mjs +2228 -0
- package/dist/lib/chunk-src.mjs.map +1 -0
- package/dist/lib/index.mjs +2 -0
- package/dist/lib/testing.mjs +1923 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/types/src/components/Block/HtmlBlock.d.ts +14 -0
- package/dist/types/src/components/Block/HtmlBlock.d.ts.map +1 -0
- package/dist/types/src/components/Block/MarkdownBlock.d.ts +24 -0
- package/dist/types/src/components/Block/MarkdownBlock.d.ts.map +1 -0
- package/dist/types/src/components/Block/block.stories.d.ts +21 -0
- package/dist/types/src/components/Block/block.stories.d.ts.map +1 -0
- package/dist/types/src/components/Block/construction.d.ts +36 -0
- package/dist/types/src/components/Block/construction.d.ts.map +1 -0
- package/dist/types/src/components/Block/extensions.d.ts +23 -0
- package/dist/types/src/components/Block/extensions.d.ts.map +1 -0
- package/dist/types/src/components/Block/highlight.d.ts +14 -0
- package/dist/types/src/components/Block/highlight.d.ts.map +1 -0
- package/dist/types/src/components/Block/index.d.ts +6 -0
- package/dist/types/src/components/Block/index.d.ts.map +1 -0
- package/dist/types/src/components/Block/selection-group.d.ts +21 -0
- package/dist/types/src/components/Block/selection-group.d.ts.map +1 -0
- package/dist/types/src/components/Block/widget-state.d.ts +33 -0
- package/dist/types/src/components/Block/widget-state.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/MessageList.d.ts +129 -0
- package/dist/types/src/components/MessageList/MessageList.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/MessageList.stories.d.ts +47 -0
- package/dist/types/src/components/MessageList/MessageList.stories.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/index.d.ts +3 -0
- package/dist/types/src/components/MessageList/index.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/position-log.d.ts +73 -0
- package/dist/types/src/components/MessageList/position-log.d.ts.map +1 -0
- package/dist/types/src/components/Minimap/Minimap.d.ts +28 -0
- package/dist/types/src/components/Minimap/Minimap.d.ts.map +1 -0
- package/dist/types/src/components/Minimap/Minimap.stories.d.ts +21 -0
- package/dist/types/src/components/Minimap/Minimap.stories.d.ts.map +1 -0
- package/dist/types/src/components/Minimap/index.d.ts +2 -0
- package/dist/types/src/components/Minimap/index.d.ts.map +1 -0
- package/dist/types/src/components/Outline/Outline.d.ts +46 -0
- package/dist/types/src/components/Outline/Outline.d.ts.map +1 -0
- package/dist/types/src/components/Outline/Outline.stories.d.ts +37 -0
- package/dist/types/src/components/Outline/Outline.stories.d.ts.map +1 -0
- package/dist/types/src/components/Outline/index.d.ts +2 -0
- package/dist/types/src/components/Outline/index.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +5 -0
- package/dist/types/src/components/index.d.ts.map +1 -0
- package/dist/types/src/debug/Debug.d.ts +17 -0
- package/dist/types/src/debug/Debug.d.ts.map +1 -0
- package/dist/types/src/debug/Stats.d.ts +30 -0
- package/dist/types/src/debug/Stats.d.ts.map +1 -0
- package/dist/types/src/debug/debug-model.d.ts +32 -0
- package/dist/types/src/debug/debug-model.d.ts.map +1 -0
- package/dist/types/src/debug/debug-model.test.d.ts +2 -0
- package/dist/types/src/debug/debug-model.test.d.ts.map +1 -0
- package/dist/types/src/debug/frame-meter.d.ts +48 -0
- package/dist/types/src/debug/frame-meter.d.ts.map +1 -0
- package/dist/types/src/debug/frame-meter.test.d.ts +2 -0
- package/dist/types/src/debug/frame-meter.test.d.ts.map +1 -0
- package/dist/types/src/debug/index.d.ts +6 -0
- package/dist/types/src/debug/index.d.ts.map +1 -0
- package/dist/types/src/debug/use-debug.d.ts +19 -0
- package/dist/types/src/debug/use-debug.d.ts.map +1 -0
- package/dist/types/src/hooks/follow.d.ts +89 -0
- package/dist/types/src/hooks/follow.d.ts.map +1 -0
- package/dist/types/src/hooks/follow.stories.d.ts +25 -0
- package/dist/types/src/hooks/follow.stories.d.ts.map +1 -0
- package/dist/types/src/hooks/follow.test.d.ts +2 -0
- package/dist/types/src/hooks/follow.test.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +6 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -0
- package/dist/types/src/hooks/useDecorations.d.ts +31 -0
- package/dist/types/src/hooks/useDecorations.d.ts.map +1 -0
- package/dist/types/src/hooks/useFeedNavigation.d.ts +44 -0
- package/dist/types/src/hooks/useFeedNavigation.d.ts.map +1 -0
- package/dist/types/src/hooks/useFollow.d.ts +46 -0
- package/dist/types/src/hooks/useFollow.d.ts.map +1 -0
- package/dist/types/src/hooks/useItemSelection.d.ts +34 -0
- package/dist/types/src/hooks/useItemSelection.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/model/feed-model.d.ts +67 -0
- package/dist/types/src/model/feed-model.d.ts.map +1 -0
- package/dist/types/src/model/feed-model.test.d.ts +2 -0
- package/dist/types/src/model/feed-model.test.d.ts.map +1 -0
- package/dist/types/src/model/feed.d.ts +66 -0
- package/dist/types/src/model/feed.d.ts.map +1 -0
- package/dist/types/src/model/feed.test.d.ts +2 -0
- package/dist/types/src/model/feed.test.d.ts.map +1 -0
- package/dist/types/src/model/index.d.ts +5 -0
- package/dist/types/src/model/index.d.ts.map +1 -0
- package/dist/types/src/model/list-model.d.ts +61 -0
- package/dist/types/src/model/list-model.d.ts.map +1 -0
- package/dist/types/src/model/list-model.test.d.ts +2 -0
- package/dist/types/src/model/list-model.test.d.ts.map +1 -0
- package/dist/types/src/model/useListModel.d.ts +22 -0
- package/dist/types/src/model/useListModel.d.ts.map +1 -0
- package/dist/types/src/stories/assistant.stories.d.ts +38 -0
- package/dist/types/src/stories/assistant.stories.d.ts.map +1 -0
- package/dist/types/src/stories/baseline.stories.d.ts +32 -0
- package/dist/types/src/stories/baseline.stories.d.ts.map +1 -0
- package/dist/types/src/stories/bridge.stories.d.ts +72 -0
- package/dist/types/src/stories/bridge.stories.d.ts.map +1 -0
- package/dist/types/src/stories/construction.stories.d.ts +17 -0
- package/dist/types/src/stories/construction.stories.d.ts.map +1 -0
- package/dist/types/src/stories/fill.stories.d.ts +51 -0
- package/dist/types/src/stories/fill.stories.d.ts.map +1 -0
- package/dist/types/src/stories/horizontal.stories.d.ts +17 -0
- package/dist/types/src/stories/horizontal.stories.d.ts.map +1 -0
- package/dist/types/src/stories/mount.stories.d.ts +19 -0
- package/dist/types/src/stories/mount.stories.d.ts.map +1 -0
- package/dist/types/src/stories/navigation.stories.d.ts +33 -0
- package/dist/types/src/stories/navigation.stories.d.ts.map +1 -0
- package/dist/types/src/stories/streaming.stories.d.ts +17 -0
- package/dist/types/src/stories/streaming.stories.d.ts.map +1 -0
- package/dist/types/src/stories/tail.stories.d.ts +47 -0
- package/dist/types/src/stories/tail.stories.d.ts.map +1 -0
- package/dist/types/src/stories/widget.stories.d.ts +16 -0
- package/dist/types/src/stories/widget.stories.d.ts.map +1 -0
- package/dist/types/src/testing/FeedStory.d.ts +30 -0
- package/dist/types/src/testing/FeedStory.d.ts.map +1 -0
- package/dist/types/src/testing/MessageWindow.d.ts +48 -0
- package/dist/types/src/testing/MessageWindow.d.ts.map +1 -0
- package/dist/types/src/testing/controls.d.ts +46 -0
- package/dist/types/src/testing/controls.d.ts.map +1 -0
- package/dist/types/src/testing/debug/FeedStats.d.ts +18 -0
- package/dist/types/src/testing/debug/FeedStats.d.ts.map +1 -0
- package/dist/types/src/testing/debug/index.d.ts +4 -0
- package/dist/types/src/testing/debug/index.d.ts.map +1 -0
- package/dist/types/src/testing/debug/sweep.d.ts +18 -0
- package/dist/types/src/testing/debug/sweep.d.ts.map +1 -0
- package/dist/types/src/testing/debug/use-feed-debug.d.ts +33 -0
- package/dist/types/src/testing/debug/use-feed-debug.d.ts.map +1 -0
- package/dist/types/src/testing/generator.d.ts +16 -0
- package/dist/types/src/testing/generator.d.ts.map +1 -0
- package/dist/types/src/testing/index.d.ts +10 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/scenarios.d.ts +58 -0
- package/dist/types/src/testing/scenarios.d.ts.map +1 -0
- package/dist/types/src/testing/stream.d.ts +22 -0
- package/dist/types/src/testing/stream.d.ts.map +1 -0
- package/dist/types/src/testing/turn.d.ts +21 -0
- package/dist/types/src/testing/turn.d.ts.map +1 -0
- package/dist/types/src/testing/widgets.d.ts +3 -0
- package/dist/types/src/testing/widgets.d.ts.map +1 -0
- package/dist/types/src/virtualizer/Window.d.ts +137 -0
- package/dist/types/src/virtualizer/Window.d.ts.map +1 -0
- package/dist/types/src/virtualizer/Window.stories.d.ts +112 -0
- package/dist/types/src/virtualizer/Window.stories.d.ts.map +1 -0
- package/dist/types/src/virtualizer/index.d.ts +3 -0
- package/dist/types/src/virtualizer/index.d.ts.map +1 -0
- package/dist/types/src/virtualizer/placement.d.ts +188 -0
- package/dist/types/src/virtualizer/placement.d.ts.map +1 -0
- package/dist/types/src/virtualizer/placement.test.d.ts +2 -0
- package/dist/types/src/virtualizer/placement.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +72 -0
- package/src/components/Block/HtmlBlock.tsx +26 -0
- package/src/components/Block/MarkdownBlock.tsx +157 -0
- package/src/components/Block/block.stories.tsx +140 -0
- package/src/components/Block/construction.ts +77 -0
- package/src/components/Block/extensions.ts +95 -0
- package/src/components/Block/highlight.ts +53 -0
- package/src/components/Block/index.ts +10 -0
- package/src/components/Block/selection-group.ts +53 -0
- package/src/components/Block/widget-state.tsx +75 -0
- package/src/components/MessageList/MessageList.stories.tsx +111 -0
- package/src/components/MessageList/MessageList.tsx +647 -0
- package/src/components/MessageList/index.ts +6 -0
- package/src/components/MessageList/position-log.ts +167 -0
- package/src/components/Minimap/Minimap.stories.tsx +129 -0
- package/src/components/Minimap/Minimap.tsx +84 -0
- package/src/components/Minimap/index.ts +5 -0
- package/src/components/Outline/Outline.stories.tsx +196 -0
- package/src/components/Outline/Outline.tsx +383 -0
- package/src/components/Outline/index.ts +5 -0
- package/src/components/index.ts +8 -0
- package/src/debug/Debug.tsx +90 -0
- package/src/debug/Stats.tsx +57 -0
- package/src/debug/debug-model.test.ts +36 -0
- package/src/debug/debug-model.ts +66 -0
- package/src/debug/frame-meter.test.ts +49 -0
- package/src/debug/frame-meter.ts +188 -0
- package/src/debug/index.ts +9 -0
- package/src/debug/use-debug.tsx +41 -0
- package/src/hooks/follow.stories.tsx +200 -0
- package/src/hooks/follow.test.ts +105 -0
- package/src/hooks/follow.ts +241 -0
- package/src/hooks/index.ts +9 -0
- package/src/hooks/useDecorations.tsx +54 -0
- package/src/hooks/useFeedNavigation.ts +90 -0
- package/src/hooks/useFollow.ts +258 -0
- package/src/hooks/useItemSelection.tsx +53 -0
- package/src/index.ts +7 -0
- package/src/model/feed-model.test.ts +84 -0
- package/src/model/feed-model.ts +214 -0
- package/src/model/feed.test.ts +65 -0
- package/src/model/feed.ts +157 -0
- package/src/model/index.ts +8 -0
- package/src/model/list-model.test.ts +71 -0
- package/src/model/list-model.ts +152 -0
- package/src/model/useListModel.ts +53 -0
- package/src/stories/assistant.stories.tsx +425 -0
- package/src/stories/baseline.stories.tsx +78 -0
- package/src/stories/bridge.stories.tsx +376 -0
- package/src/stories/construction.stories.tsx +147 -0
- package/src/stories/fill.stories.tsx +271 -0
- package/src/stories/horizontal.stories.tsx +129 -0
- package/src/stories/mount.stories.tsx +233 -0
- package/src/stories/navigation.stories.tsx +145 -0
- package/src/stories/streaming.stories.tsx +132 -0
- package/src/stories/tail.stories.tsx +149 -0
- package/src/stories/widget.stories.tsx +107 -0
- package/src/testing/FeedStory.tsx +452 -0
- package/src/testing/MessageWindow.tsx +139 -0
- package/src/testing/controls.tsx +96 -0
- package/src/testing/debug/FeedStats.tsx +182 -0
- package/src/testing/debug/index.ts +16 -0
- package/src/testing/debug/sweep.ts +85 -0
- package/src/testing/debug/use-feed-debug.ts +91 -0
- package/src/testing/generator.ts +90 -0
- package/src/testing/index.ts +13 -0
- package/src/testing/scenarios.tsx +552 -0
- package/src/testing/stream.ts +70 -0
- package/src/testing/turn.ts +91 -0
- package/src/testing/widgets.tsx +183 -0
- package/src/virtualizer/Window.stories.tsx +683 -0
- package/src/virtualizer/Window.tsx +435 -0
- package/src/virtualizer/index.ts +6 -0
- package/src/virtualizer/placement.test.ts +312 -0
- package/src/virtualizer/placement.ts +403 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Atom from 'effect/unstable/reactivity/Atom';
|
|
6
|
+
import * as Registry from 'effect/unstable/reactivity/AtomRegistry';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What one change to the model actually was — told, never inferred.
|
|
10
|
+
*
|
|
11
|
+
* The virtualizer's anchor survives a prepend only if it knows the prepend happened (SPEC F-7.1);
|
|
12
|
+
* the engine used to reconstruct that by scanning for the previous first id, which is the model's
|
|
13
|
+
* knowledge recovered from its absence. Here the model says so. The identity scan still exists in
|
|
14
|
+
* exactly one place — `replace`, the adapter for hosts that hand over a whole new array — because
|
|
15
|
+
* that is the one caller that genuinely does not know.
|
|
16
|
+
*/
|
|
17
|
+
export type ListChange = {
|
|
18
|
+
prepended?: number;
|
|
19
|
+
appended?: number;
|
|
20
|
+
/** Ids whose content changed in place — a streaming tail, an edit. */
|
|
21
|
+
updated?: readonly string[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type ListModelOptions<T> = {
|
|
25
|
+
items?: readonly T[];
|
|
26
|
+
/** Identity of an item. Measurements, selection and decorations are all keyed by this. */
|
|
27
|
+
getId: (item: T) => string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The low-level reactive collection the virtualizer binds to (SPEC: ListModel).
|
|
32
|
+
*
|
|
33
|
+
* Two faces on one state, deliberately: a plain synchronous face (`count`, `at`, `subscribe`) that
|
|
34
|
+
* the virtualizer consumes without dragging an atom registry into every story and test, and an
|
|
35
|
+
* atom face (`rowsAtom`) for hosts that live in atom-land — derivations (stops, decorations,
|
|
36
|
+
* readouts) hang off the atom rather than becoming parallel props. Both are the same array; the
|
|
37
|
+
* subscribe face is not a fallback but the binding's contract.
|
|
38
|
+
*/
|
|
39
|
+
export class ListModel<T> {
|
|
40
|
+
#items: T[];
|
|
41
|
+
readonly #getId: (item: T) => string;
|
|
42
|
+
readonly #listeners = new Set<(change: ListChange) => void>();
|
|
43
|
+
|
|
44
|
+
/** The rows as an atom, for derivations. Registry-managed by whoever consumes it. */
|
|
45
|
+
readonly rowsAtom: Atom.Writable<readonly T[]>;
|
|
46
|
+
readonly #registry: Registry.AtomRegistry;
|
|
47
|
+
|
|
48
|
+
constructor({ items = [], getId }: ListModelOptions<T>) {
|
|
49
|
+
this.#items = [...items];
|
|
50
|
+
this.#getId = getId;
|
|
51
|
+
this.rowsAtom = Atom.make<readonly T[]>(this.#items);
|
|
52
|
+
// A model owns its registry so the atom face works without a provider; a host embedded in a
|
|
53
|
+
// wider registry can still read `rowsAtom` through its own.
|
|
54
|
+
this.#registry = Registry.make();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get count(): number {
|
|
58
|
+
return this.#items.length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get items(): readonly T[] {
|
|
62
|
+
return this.#items;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
getId = (index: number): string => {
|
|
66
|
+
const item = this.#items[index];
|
|
67
|
+
return item === undefined ? `missing-${index}` : this.#getId(item);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
at = (index: number): T | undefined => this.#items[index];
|
|
71
|
+
|
|
72
|
+
indexOf(id: string): number {
|
|
73
|
+
return this.#items.findIndex((item) => this.#getId(item) === id);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
subscribe(listener: (change: ListChange) => void): () => void {
|
|
77
|
+
this.#listeners.add(listener);
|
|
78
|
+
return () => this.#listeners.delete(listener);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
append(items: readonly T[]): void {
|
|
82
|
+
if (!items.length) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.#items = [...this.#items, ...items];
|
|
87
|
+
this.#publish({ appended: items.length });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
prepend(items: readonly T[]): void {
|
|
91
|
+
if (!items.length) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.#items = [...items, ...this.#items];
|
|
96
|
+
this.#publish({ prepended: items.length });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** An item changed in place — same identity, new content. */
|
|
100
|
+
update(id: string): void {
|
|
101
|
+
this.#publish({ updated: [id] });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Replace one item with a successor of the same identity — the streaming case for *frozen*
|
|
106
|
+
* items. An ECHO object mutates and `update` announces it; a schema-made message is frozen, so
|
|
107
|
+
* the chunk arrives as a fresh value under the old id. Same event either way: `updated`.
|
|
108
|
+
*/
|
|
109
|
+
patch(id: string, next: T): void {
|
|
110
|
+
const index = this.#items.findIndex((item) => this.#getId(item) === id);
|
|
111
|
+
if (index < 0 || this.#getId(next) !== id) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
this.#items = [...this.#items.slice(0, index), next, ...this.#items.slice(index + 1)];
|
|
116
|
+
this.#publish({ updated: [id] });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The adapter for hosts that hand over a whole new array (a React prop, a query result).
|
|
121
|
+
*
|
|
122
|
+
* The one place the prepend inference survives, because this is the one caller that genuinely
|
|
123
|
+
* does not know what changed: the old first item is found in the new array, and everything before
|
|
124
|
+
* it was prepended. The virtualizer itself is always told (SPEC F-7.1).
|
|
125
|
+
*/
|
|
126
|
+
replace(items: readonly T[]): void {
|
|
127
|
+
const previous = this.#items;
|
|
128
|
+
const previousFirst = previous.length ? this.#getId(previous[0]) : undefined;
|
|
129
|
+
this.#items = [...items];
|
|
130
|
+
|
|
131
|
+
let prepended = 0;
|
|
132
|
+
if (previousFirst !== undefined && items.length > previous.length) {
|
|
133
|
+
const grew = items.length - previous.length;
|
|
134
|
+
for (let index = 1; index <= grew; index++) {
|
|
135
|
+
if (items[index] !== undefined && this.#getId(items[index]) === previousFirst) {
|
|
136
|
+
prepended = index;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const appended = prepended ? 0 : Math.max(0, items.length - previous.length);
|
|
143
|
+
this.#publish({ prepended: prepended || undefined, appended: appended || undefined });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#publish(change: ListChange): void {
|
|
147
|
+
this.#registry.set(this.rowsAtom, this.#items);
|
|
148
|
+
for (const listener of this.#listeners) {
|
|
149
|
+
listener(change);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { useEffect, useMemo } from 'react';
|
|
6
|
+
|
|
7
|
+
import { type Message } from '@dxos/types';
|
|
8
|
+
|
|
9
|
+
import { FeedModel, type FeedModelOptions } from './feed';
|
|
10
|
+
import { ListModel } from './list-model';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A model over an array the host re-creates per render — the React-prop case.
|
|
14
|
+
*
|
|
15
|
+
* The model is stable; the array is folded into it with `replace`, which is where the prepend
|
|
16
|
+
* inference lives (and the only place — SPEC F-7.1). In an effect rather than during render: the
|
|
17
|
+
* model publishes synchronously to its subscribers, and a subscriber invalidating itself while the
|
|
18
|
+
* host is still rendering is a cross-component update React rejects. The window it opens is one
|
|
19
|
+
* frame, and the window's own subscription closes it.
|
|
20
|
+
*/
|
|
21
|
+
export const useListModel = <T>(items: readonly T[], getId: (item: T) => string): ListModel<T> => {
|
|
22
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
23
|
+
const model = useMemo(() => new ListModel({ items, getId }), []);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (model.items !== items) {
|
|
26
|
+
model.replace(items);
|
|
27
|
+
}
|
|
28
|
+
}, [model, items]);
|
|
29
|
+
|
|
30
|
+
return model;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The feed-shaped twin, for a host that re-creates its message array per render.
|
|
35
|
+
*
|
|
36
|
+
* Streaming through this adapter is a `replace` whose length does not change; the model still
|
|
37
|
+
* publishes, so the window still re-renders, and the streaming item reconciles by document delta
|
|
38
|
+
* exactly as it would under a told update.
|
|
39
|
+
*/
|
|
40
|
+
export const useFeedModel = (
|
|
41
|
+
messages: readonly Message.Message[],
|
|
42
|
+
options: Omit<FeedModelOptions, 'messages'> = {},
|
|
43
|
+
): FeedModel => {
|
|
44
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
45
|
+
const model = useMemo(() => new FeedModel({ messages, ...options }), []);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (model.messages !== messages) {
|
|
48
|
+
model.replace(messages);
|
|
49
|
+
}
|
|
50
|
+
}, [model, messages]);
|
|
51
|
+
|
|
52
|
+
return model;
|
|
53
|
+
};
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
|
+
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
|
7
|
+
import { expect } from 'storybook/test';
|
|
8
|
+
|
|
9
|
+
import { IconButton, Input, Panel, Toolbar } from '@dxos/react-ui';
|
|
10
|
+
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
11
|
+
import { Message } from '@dxos/types';
|
|
12
|
+
|
|
13
|
+
import { Outline, type OutlineMarker } from '../components';
|
|
14
|
+
import { MessageList, useMessageList } from '../components';
|
|
15
|
+
import { Debug, DebugProvider, useDebugProbes, useFrameMeter } from '../debug';
|
|
16
|
+
import { FeedModel } from '../model';
|
|
17
|
+
import { createScenario, streamTurn } from '../testing';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The plugin-assistant use case, end to end: a reader typing prompts into a conversation while an
|
|
21
|
+
* agent streams multi-block answers into its tail — status, reasoning, a tool call and its result,
|
|
22
|
+
* then the answer, exactly the block sequence a model emits (`streamTurn`).
|
|
23
|
+
*
|
|
24
|
+
* Everything here goes through the shipping path and the model's *told* APIs: a typed prompt is
|
|
25
|
+
* `model.append`, the agent's turn mutates one identity via `model.stream`, and the follow keeps
|
|
26
|
+
* the tail at rest because the reader is there — scrolling up withdraws it, returning restores it.
|
|
27
|
+
* The `debug` arg turns on the generic Debug table (`@dxos/react-ui-feed/debug`), with probes over
|
|
28
|
+
* the frame rate, the model, the window and the widget census — every aspect readable live.
|
|
29
|
+
*/
|
|
30
|
+
type StoryArgs = {
|
|
31
|
+
count?: number;
|
|
32
|
+
debug?: boolean;
|
|
33
|
+
scrollPastEnd?: boolean;
|
|
34
|
+
wordsPerChunk?: number;
|
|
35
|
+
chunkDelay?: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const DefaultStory = ({ count = 30, debug, scrollPastEnd, wordsPerChunk = 4, chunkDelay = 120 }: StoryArgs) => {
|
|
39
|
+
const definition = useMemo(() => createScenario({ scenario: 'assistant', count }), [count]);
|
|
40
|
+
// The model, not an array: prompts and answers are told to it, and the window is told in turn.
|
|
41
|
+
const model = useMemo(() => new FeedModel({ messages: definition.messages, stops: 'prompt' }), [definition]);
|
|
42
|
+
|
|
43
|
+
const [busy, setBusy] = useState(false);
|
|
44
|
+
const [auto, setAuto] = useState(false);
|
|
45
|
+
const autoRef = useRef(false);
|
|
46
|
+
|
|
47
|
+
// One agent turn: the answer arrives as blocks that shrink and rewrite as well as grow.
|
|
48
|
+
const answer = useCallback(
|
|
49
|
+
async (prompt: string) => {
|
|
50
|
+
setBusy(true);
|
|
51
|
+
model.append([
|
|
52
|
+
Message.make({ sender: { role: 'user', name: 'rich' }, blocks: [{ _tag: 'text', text: prompt }] }),
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const reply = Message.make({ sender: { role: 'assistant', name: 'Assistant' }, blocks: [] });
|
|
56
|
+
model.append([reply]);
|
|
57
|
+
model.setStreaming(reply.id);
|
|
58
|
+
try {
|
|
59
|
+
for await (const blocks of streamTurn({ wordsPerChunk, chunkDelay })) {
|
|
60
|
+
// Patched, not mutated: a schema-made message is frozen, so each chunk is a fresh value
|
|
61
|
+
// under the same identity — which is also what an item needs to reconcile by delta.
|
|
62
|
+
model.patch(reply.id, { ...reply, blocks } as Message.Message);
|
|
63
|
+
}
|
|
64
|
+
} finally {
|
|
65
|
+
model.setStreaming(undefined);
|
|
66
|
+
setBusy(false);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
[model, wordsPerChunk, chunkDelay],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Hands-off mode for review: turns keep arriving until stopped, as an unbounded conversation.
|
|
73
|
+
const toggleAuto = useCallback(() => {
|
|
74
|
+
const next = !autoRef.current;
|
|
75
|
+
autoRef.current = next;
|
|
76
|
+
setAuto(next);
|
|
77
|
+
if (next) {
|
|
78
|
+
void (async () => {
|
|
79
|
+
let turn = 0;
|
|
80
|
+
while (autoRef.current) {
|
|
81
|
+
await answer(`Question ${++turn}: what changed since the last answer?`);
|
|
82
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
83
|
+
}
|
|
84
|
+
})();
|
|
85
|
+
}
|
|
86
|
+
}, [answer]);
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<DebugProvider>
|
|
90
|
+
<MessageList.Root
|
|
91
|
+
model={model}
|
|
92
|
+
renderer={definition.renderer}
|
|
93
|
+
registry={definition.registry}
|
|
94
|
+
Chrome={definition.Chrome}
|
|
95
|
+
estimateSize={definition.estimateSize}
|
|
96
|
+
stickyBottom
|
|
97
|
+
scrollPastEnd={scrollPastEnd}
|
|
98
|
+
>
|
|
99
|
+
<Panel.Root>
|
|
100
|
+
<Panel.Toolbar asChild>
|
|
101
|
+
<Toolbar.Root>
|
|
102
|
+
<IconButton
|
|
103
|
+
icon={auto ? 'ph--stop--regular' : 'ph--play--regular'}
|
|
104
|
+
iconOnly
|
|
105
|
+
label={auto ? 'Stop the agent' : 'Let the agent talk'}
|
|
106
|
+
data-testid='assistant.auto'
|
|
107
|
+
onClick={toggleAuto}
|
|
108
|
+
/>
|
|
109
|
+
<div className='grow' />
|
|
110
|
+
<NavButtons />
|
|
111
|
+
</Toolbar.Root>
|
|
112
|
+
</Panel.Toolbar>
|
|
113
|
+
|
|
114
|
+
{/* Inside Panel.Content, not a sibling: Panel.Root is a grid whose rows are its slots, so
|
|
115
|
+
a loose child lands in an implicit row under the toolbar rather than at the bottom. */}
|
|
116
|
+
<Panel.Content classNames='dx-container flex flex-col min-h-0'>
|
|
117
|
+
<div className='relative grow min-h-0'>
|
|
118
|
+
<div className='z-10 absolute left-0 top-0 bottom-0 grid grid-rows-[1fr_4fr_1fr] justify-center'>
|
|
119
|
+
<PromptOutline classNames='row-start-2' model={model} />
|
|
120
|
+
</div>
|
|
121
|
+
<MessageList.Viewport classNames='absolute inset-0' padding />
|
|
122
|
+
{debug && <Probes model={model} />}
|
|
123
|
+
</div>
|
|
124
|
+
<PromptInput busy={busy} onSubmit={(prompt) => void answer(prompt)} />
|
|
125
|
+
</Panel.Content>
|
|
126
|
+
</Panel.Root>
|
|
127
|
+
{debug && <Debug />}
|
|
128
|
+
</MessageList.Root>
|
|
129
|
+
</DebugProvider>
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** The prompt box: the reader's half of the conversation. */
|
|
134
|
+
const PromptInput = ({ busy, onSubmit }: { busy: boolean; onSubmit: (prompt: string) => void }) => {
|
|
135
|
+
const [prompt, setPrompt] = useState('');
|
|
136
|
+
const submit = () => {
|
|
137
|
+
const text = prompt.trim();
|
|
138
|
+
if (text.length) {
|
|
139
|
+
onSubmit(text);
|
|
140
|
+
setPrompt('');
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div className='p-2 border-bs border-separator'>
|
|
146
|
+
<Input.Root>
|
|
147
|
+
<Input.TextInput
|
|
148
|
+
placeholder={busy ? 'Answering…' : 'Ask something…'}
|
|
149
|
+
value={prompt}
|
|
150
|
+
data-testid='assistant.prompt'
|
|
151
|
+
onChange={(event) => setPrompt(event.target.value)}
|
|
152
|
+
onKeyDown={(event) => event.key === 'Enter' && submit()}
|
|
153
|
+
/>
|
|
154
|
+
</Input.Root>
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** The outline rail over the model's own stops, stepping through the same seam as everything else. */
|
|
160
|
+
const PromptOutline = ({ classNames, model }: { classNames?: string; model: FeedModel }) => {
|
|
161
|
+
const { currentIndex, navigation, count } = useMessageList('PromptOutline');
|
|
162
|
+
|
|
163
|
+
const markers = useMemo<OutlineMarker[]>(
|
|
164
|
+
() =>
|
|
165
|
+
model.stops().map(({ index, id }) => ({
|
|
166
|
+
id,
|
|
167
|
+
title: `#${index}`,
|
|
168
|
+
description: model.textOf(id)?.slice(0, 160),
|
|
169
|
+
range: { from: index, to: index + 1 },
|
|
170
|
+
})),
|
|
171
|
+
// Recomputed per count change: stops are a policy over the model.
|
|
172
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
173
|
+
[model, count],
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const current = useMemo(() => {
|
|
177
|
+
const marker = [...markers].reverse().find(({ range: { from } }) => from <= currentIndex) ?? markers[0];
|
|
178
|
+
return marker?.range;
|
|
179
|
+
}, [markers, currentIndex]);
|
|
180
|
+
|
|
181
|
+
if (!markers.length) {
|
|
182
|
+
return <div />;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<Outline
|
|
187
|
+
classNames={['justify-self-center', classNames]}
|
|
188
|
+
markers={markers}
|
|
189
|
+
visibleRange={current}
|
|
190
|
+
onSelect={(marker) => navigation.jumpTo(marker.range.from, 'smooth')}
|
|
191
|
+
onNavigate={(delta) => navigation.step(delta)}
|
|
192
|
+
/>
|
|
193
|
+
);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const NavButtons = () => {
|
|
197
|
+
const { navigation } = useMessageList('NavButtons');
|
|
198
|
+
return (
|
|
199
|
+
<>
|
|
200
|
+
<IconButton
|
|
201
|
+
icon='ph--caret-up--regular'
|
|
202
|
+
iconOnly
|
|
203
|
+
label='Previous prompt'
|
|
204
|
+
data-testid='assistant.prev'
|
|
205
|
+
onClick={() => navigation.step(-1)}
|
|
206
|
+
/>
|
|
207
|
+
<IconButton
|
|
208
|
+
icon='ph--caret-down--regular'
|
|
209
|
+
iconOnly
|
|
210
|
+
label='Next prompt'
|
|
211
|
+
data-testid='assistant.next'
|
|
212
|
+
onClick={() => navigation.step(1)}
|
|
213
|
+
/>
|
|
214
|
+
</>
|
|
215
|
+
);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/** Probes over every aspect, registered into the Debug table for as long as they are mounted. */
|
|
219
|
+
const Probes = ({ model }: { model: FeedModel }) => {
|
|
220
|
+
const meter = useFrameMeter();
|
|
221
|
+
const { range, mountedRows, mountedWidgets, jumps } = useMessageList('Probes');
|
|
222
|
+
const state = useRef({ range, mountedRows, mountedWidgets, jumps, model });
|
|
223
|
+
state.current = { range, mountedRows, mountedWidgets, jumps, model };
|
|
224
|
+
|
|
225
|
+
useDebugProbes(() => [
|
|
226
|
+
{
|
|
227
|
+
id: 'fps',
|
|
228
|
+
group: 'frames',
|
|
229
|
+
read: () => Math.round(meter.fps),
|
|
230
|
+
alarm: (value) => Number(value) < 30,
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
id: 'worst',
|
|
234
|
+
group: 'frames',
|
|
235
|
+
unit: 'ms',
|
|
236
|
+
read: () => Math.round(meter.worst),
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: 'count',
|
|
240
|
+
group: 'model',
|
|
241
|
+
read: () => state.current.model.count,
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: 'streaming',
|
|
245
|
+
group: 'model',
|
|
246
|
+
read: () => (state.current.model.streamingId ? 'yes' : '—'),
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
id: 'range',
|
|
250
|
+
group: 'window',
|
|
251
|
+
read: () => {
|
|
252
|
+
const { range } = state.current;
|
|
253
|
+
return range ? `${range.startIndex}–${range.endIndex}` : '—';
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: 'mounted',
|
|
258
|
+
group: 'window',
|
|
259
|
+
read: () => state.current.mountedRows,
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
id: 'widgets',
|
|
263
|
+
group: 'window',
|
|
264
|
+
read: () => state.current.mountedWidgets,
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
id: 'jumps',
|
|
268
|
+
group: 'window',
|
|
269
|
+
read: () => state.current.jumps.count,
|
|
270
|
+
alarm: (value) => Number(value) > 0,
|
|
271
|
+
},
|
|
272
|
+
]);
|
|
273
|
+
|
|
274
|
+
return null;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const meta: Meta<StoryArgs> = {
|
|
278
|
+
title: 'ui/react-ui-feed/stories/assistant',
|
|
279
|
+
render: DefaultStory,
|
|
280
|
+
decorators: [withLayout({ layout: 'column', classNames: 'w-[50rem]' }), withTheme()],
|
|
281
|
+
parameters: { layout: 'fullscreen' },
|
|
282
|
+
argTypes: {
|
|
283
|
+
debug: { control: 'boolean' },
|
|
284
|
+
scrollPastEnd: { control: 'boolean' },
|
|
285
|
+
},
|
|
286
|
+
args: { count: 30, debug: false, scrollPastEnd: true },
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
export default meta;
|
|
290
|
+
|
|
291
|
+
type Story = StoryObj<StoryArgs>;
|
|
292
|
+
|
|
293
|
+
const nextFrame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve));
|
|
294
|
+
|
|
295
|
+
const settle = async (frames = 30) => {
|
|
296
|
+
for (let frame = 0; frame < frames; frame++) {
|
|
297
|
+
await nextFrame();
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/** Reflects a typed value into a React-controlled input: the native setter, then the event. */
|
|
302
|
+
const type = (input: HTMLInputElement, value: string) => {
|
|
303
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!;
|
|
304
|
+
setter.call(input, value);
|
|
305
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
/** The loop, hands on: type a prompt, or press ▶ and watch. No play — this one is for people. */
|
|
309
|
+
export const Default: Story = {};
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The whole conversation loop, driven as a reader would drive it.
|
|
313
|
+
*
|
|
314
|
+
* A prompt is typed and entered; the agent answers in blocks; the tail stays at rest through the
|
|
315
|
+
* stream (the follow at work); scrolling up mid-answer withdraws the follow and the feed holds
|
|
316
|
+
* still under a growing document.
|
|
317
|
+
*/
|
|
318
|
+
export const Assistant: Story = {
|
|
319
|
+
args: { chunkDelay: 40 },
|
|
320
|
+
play: async ({ canvasElement }) => {
|
|
321
|
+
await settle(40);
|
|
322
|
+
const scroller = canvasElement.querySelector<HTMLElement>('[data-testid="feed.viewport"]')!;
|
|
323
|
+
const atTail = () => {
|
|
324
|
+
const rows = [...scroller.querySelectorAll<HTMLElement>('[data-index]')];
|
|
325
|
+
const last = rows[rows.length - 1];
|
|
326
|
+
return !!last && Math.abs(last.getBoundingClientRect().bottom - scroller.getBoundingClientRect().bottom) <= 2;
|
|
327
|
+
};
|
|
328
|
+
const countRows = () => scroller.querySelectorAll('[data-index]').length > 0;
|
|
329
|
+
|
|
330
|
+
// 1. The reader asks. "Asked" is read from what a reader would see: the input cleared on
|
|
331
|
+
// submit, and the turn started (the placeholder flips while the agent answers) — never from
|
|
332
|
+
// mounted-row counts, which *shrink* as taller streamed rows fill the window.
|
|
333
|
+
const input = canvasElement.querySelector<HTMLInputElement>('[data-testid="assistant.prompt"]')!;
|
|
334
|
+
type(input, 'What is an anchor-relative placement?');
|
|
335
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
|
336
|
+
await settle(10);
|
|
337
|
+
const asked = input.value === '';
|
|
338
|
+
|
|
339
|
+
// 2. The answer streams in blocks while the follow keeps the tail near rest. The glide is
|
|
340
|
+
// allowed to lag a growing tail by a bounded distance — that is what travelling means — so the
|
|
341
|
+
// reading is the worst lag during the stream, and exact rest once the turn lands.
|
|
342
|
+
const lag = () => {
|
|
343
|
+
const rows = [...scroller.querySelectorAll<HTMLElement>('[data-index]')];
|
|
344
|
+
const last = rows[rows.length - 1];
|
|
345
|
+
return last ? last.getBoundingClientRect().bottom - scroller.getBoundingClientRect().bottom : 0;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
let sawStream = false;
|
|
349
|
+
let worst = 0;
|
|
350
|
+
for (let frame = 0; frame < 600; frame++) {
|
|
351
|
+
await nextFrame();
|
|
352
|
+
sawStream = sawStream || atTail();
|
|
353
|
+
worst = Math.max(worst, lag());
|
|
354
|
+
// The turn is over when the input re-enables.
|
|
355
|
+
if (sawStream && input.placeholder !== 'Answering…') {
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Landed: arrival is by deceleration, so rest is polled rather than sampled.
|
|
361
|
+
let rests = false;
|
|
362
|
+
for (let frame = 0; frame < 300 && !rests; frame++) {
|
|
363
|
+
await nextFrame();
|
|
364
|
+
rests = atTail();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
await expect({
|
|
368
|
+
asked,
|
|
369
|
+
sawStream,
|
|
370
|
+
bounded: worst < scroller.clientHeight,
|
|
371
|
+
rests,
|
|
372
|
+
mounted: countRows(),
|
|
373
|
+
}).toEqual({
|
|
374
|
+
asked: true,
|
|
375
|
+
sawStream: true,
|
|
376
|
+
bounded: true,
|
|
377
|
+
rests: true,
|
|
378
|
+
mounted: true,
|
|
379
|
+
});
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Scrolling away mid-answer stops the follow, and it stays stopped: a reader looking at history is
|
|
385
|
+
* not dragged back by the stream. Returning to the tail opts back in.
|
|
386
|
+
*/
|
|
387
|
+
export const Interrupted: Story = {
|
|
388
|
+
args: { chunkDelay: 40, count: 60 },
|
|
389
|
+
play: async ({ canvasElement }) => {
|
|
390
|
+
await settle(40);
|
|
391
|
+
const scroller = canvasElement.querySelector<HTMLElement>('[data-testid="feed.viewport"]')!;
|
|
392
|
+
|
|
393
|
+
// Start a hands-off turn, then leave for history mid-stream.
|
|
394
|
+
(canvasElement.querySelector('[data-testid="assistant.auto"]') as HTMLElement).click();
|
|
395
|
+
await settle(30);
|
|
396
|
+
// The wheel first: a scroll the follow cannot attribute to the reader is one it will undo.
|
|
397
|
+
scroller.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: -100 }));
|
|
398
|
+
scroller.scrollTop = Math.max(0, scroller.scrollTop - 3 * scroller.clientHeight);
|
|
399
|
+
await settle(10);
|
|
400
|
+
|
|
401
|
+
const away = scroller.scrollTop;
|
|
402
|
+
await settle(60);
|
|
403
|
+
const stayed = Math.abs(scroller.scrollTop - away) <= 2;
|
|
404
|
+
|
|
405
|
+
// Returning to the tail opts back in: the next answer keeps it at rest. Without this half the
|
|
406
|
+
// story passes with no follow at all — "stays away" is trivially true then.
|
|
407
|
+
let previous = -1;
|
|
408
|
+
for (let attempt = 0; attempt < 8 && scroller.scrollTop !== previous; attempt++) {
|
|
409
|
+
previous = scroller.scrollTop;
|
|
410
|
+
scroller.scrollTop = scroller.scrollHeight;
|
|
411
|
+
await settle(10);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
let resumed = false;
|
|
415
|
+
for (let frame = 0; frame < 400 && !resumed; frame++) {
|
|
416
|
+
await nextFrame();
|
|
417
|
+
const rows = [...scroller.querySelectorAll<HTMLElement>('[data-index]')];
|
|
418
|
+
const last = rows[rows.length - 1];
|
|
419
|
+
resumed = !!last && Math.abs(last.getBoundingClientRect().bottom - scroller.getBoundingClientRect().bottom) <= 2;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
(canvasElement.querySelector('[data-testid="assistant.auto"]') as HTMLElement).click();
|
|
423
|
+
await expect({ stayed, resumed }).toEqual({ stayed: true, resumed: true });
|
|
424
|
+
},
|
|
425
|
+
};
|