@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,145 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
|
+
import { expect, within } from 'storybook/test';
|
|
7
|
+
|
|
8
|
+
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
9
|
+
|
|
10
|
+
import { FeedStory, type FeedStoryProps } from '../testing';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Arrow keys step between the feed's stops, one press one stop.
|
|
14
|
+
*
|
|
15
|
+
* The property under test is that a press always moves, and always to the adjacent stop: a smooth
|
|
16
|
+
* scroll passes through every range between here and its target, and the cursor follows the range
|
|
17
|
+
* whenever the reader scrolls past it — so a navigation that does not defend its destination
|
|
18
|
+
* overwrites it mid-flight and the next press steps from a row the animation was crossing. The
|
|
19
|
+
* symptom is an arrow that sometimes moves and sometimes returns to the row it just left.
|
|
20
|
+
*/
|
|
21
|
+
const meta: Meta<FeedStoryProps> = {
|
|
22
|
+
title: 'ui/react-ui-feed/stories/navigation',
|
|
23
|
+
render: FeedStory,
|
|
24
|
+
decorators: [withLayout({ layout: 'column', classNames: 'w-[50rem]' }), withTheme()],
|
|
25
|
+
parameters: { layout: 'fullscreen' },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default meta;
|
|
29
|
+
|
|
30
|
+
type Story = StoryObj<FeedStoryProps>;
|
|
31
|
+
|
|
32
|
+
/** Frames to let a step settle: the travel is smooth, and the readout follows the range. */
|
|
33
|
+
const SETTLE_FRAMES = 30;
|
|
34
|
+
|
|
35
|
+
const nextFrame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve));
|
|
36
|
+
|
|
37
|
+
const settle = async () => {
|
|
38
|
+
for (let frame = 0; frame < SETTLE_FRAMES; frame++) {
|
|
39
|
+
await nextFrame();
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The reading the complaint is about: where the feed actually is.
|
|
45
|
+
*
|
|
46
|
+
* Not the cursor readout, and not `scrollTop` either. The readout can name a neighbour while a stop
|
|
47
|
+
* is still being measured; and the offset now absorbs the start edge's repayments — content and
|
|
48
|
+
* scroll shift together so the reader sees nothing move, which also means the raw offset moves when
|
|
49
|
+
* nothing did. The row under the top edge is what the reader sees, so a press's travel is read as
|
|
50
|
+
* that row's index: ArrowUp must put an earlier row there, every time.
|
|
51
|
+
*/
|
|
52
|
+
const topRowOf = (canvasElement: HTMLElement): number => {
|
|
53
|
+
const viewport = within(canvasElement).getByTestId('feed.viewport');
|
|
54
|
+
const edge = viewport.getBoundingClientRect().top;
|
|
55
|
+
const rows = [...viewport.querySelectorAll<HTMLElement>('[data-index]')];
|
|
56
|
+
const row = rows.find((element) => element.getBoundingClientRect().bottom > edge + 1);
|
|
57
|
+
return row ? Number(row.dataset.index) : -1;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const press = async (viewport: HTMLElement, key: 'ArrowUp' | 'ArrowDown') => {
|
|
61
|
+
viewport.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
|
|
62
|
+
// A step glides now, so "after the press" means after the travel lands: polled to a scroll that
|
|
63
|
+
// has held still for a dozen frames, not a fixed wait that encodes one machine's glide speed.
|
|
64
|
+
let still = 0;
|
|
65
|
+
let last = viewport.scrollTop;
|
|
66
|
+
for (let frame = 0; frame < 300 && still < 12; frame++) {
|
|
67
|
+
await nextFrame();
|
|
68
|
+
const current = viewport.scrollTop;
|
|
69
|
+
still = Math.abs(current - last) < 1 ? still + 1 : 0;
|
|
70
|
+
last = current;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Passive: the feed with its stops, for driving by hand. */
|
|
75
|
+
export const Default: Story = {
|
|
76
|
+
args: { scenario: 'assistant', count: 200 },
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Every press moves, and lands on a stop.
|
|
81
|
+
*
|
|
82
|
+
* `assistant` makes every second message a prompt and only prompts are stops, so a stop is an even
|
|
83
|
+
* index — which is what makes "landed on a stop" checkable rather than merely "moved". The first
|
|
84
|
+
* press is the odd one out: the feed opens on the last message, an answer, so the nearest stop above
|
|
85
|
+
* it is one index away where every step afterwards is two.
|
|
86
|
+
*/
|
|
87
|
+
export const Arrows: Story = {
|
|
88
|
+
args: { scenario: 'assistant', count: 60 },
|
|
89
|
+
play: async ({ canvasElement }) => {
|
|
90
|
+
const viewport = within(canvasElement).getByTestId('feed.viewport');
|
|
91
|
+
await settle();
|
|
92
|
+
|
|
93
|
+
// Asserted as the whole path of top-row indices: every ArrowUp must put an earlier row under
|
|
94
|
+
// the top edge, so a failure names the press that stalled and what it did instead.
|
|
95
|
+
const rows = [topRowOf(canvasElement)];
|
|
96
|
+
for (let step = 0; step < 5; step++) {
|
|
97
|
+
await press(viewport, 'ArrowUp');
|
|
98
|
+
rows.push(topRowOf(canvasElement));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const stalled = rows.slice(1).filter((row, step) => row >= rows[step]);
|
|
102
|
+
await expect({ rows, stalled }).toEqual({ rows, stalled: [] });
|
|
103
|
+
|
|
104
|
+
// And the other way: `ArrowDown` returns towards the tail it came from. Near the end a stop's
|
|
105
|
+
// start can lie beyond the maximum offset, so "already at max scroll" counts as arrived rather
|
|
106
|
+
// than as a stall — the feed cannot advance the top row past what the viewport can hold.
|
|
107
|
+
const back = [topRowOf(canvasElement)];
|
|
108
|
+
const progressed: boolean[] = [];
|
|
109
|
+
for (let step = 0; step < 5; step++) {
|
|
110
|
+
const wasAtMax = viewport.scrollTop >= viewport.scrollHeight - viewport.clientHeight - 2;
|
|
111
|
+
await press(viewport, 'ArrowDown');
|
|
112
|
+
back.push(topRowOf(canvasElement));
|
|
113
|
+
const atMax = viewport.scrollTop >= viewport.scrollHeight - viewport.clientHeight - 2;
|
|
114
|
+
// A press made progress if the top row advanced, or it carried the viewport to (or held it
|
|
115
|
+
// at) the maximum — the last stop's start can lie beyond what the viewport can scroll to.
|
|
116
|
+
progressed.push(back[step + 1] > back[step] || atMax || wasAtMax);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const stalls = progressed.filter((ok) => !ok);
|
|
120
|
+
await expect({ back, stalls }).toEqual({ back, stalls: [] });
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The same property where every message is a stop.
|
|
126
|
+
*
|
|
127
|
+
* `plain` names no `isAnchor`, so a press steps one message — and its rows are tall and unequal,
|
|
128
|
+
* which is the case where the offset moves furthest between two stops.
|
|
129
|
+
*/
|
|
130
|
+
export const Plain: Story = {
|
|
131
|
+
args: { scenario: 'plain', count: 60 },
|
|
132
|
+
play: async ({ canvasElement }) => {
|
|
133
|
+
const viewport = within(canvasElement).getByTestId('feed.viewport');
|
|
134
|
+
await settle();
|
|
135
|
+
|
|
136
|
+
const rows = [topRowOf(canvasElement)];
|
|
137
|
+
for (let step = 0; step < 5; step++) {
|
|
138
|
+
await press(viewport, 'ArrowUp');
|
|
139
|
+
rows.push(topRowOf(canvasElement));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const stalled = rows.slice(1).filter((row, step) => row >= rows[step]);
|
|
143
|
+
await expect({ rows, stalled }).toEqual({ rows, stalled: [] });
|
|
144
|
+
},
|
|
145
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
|
+
import { expect } from 'storybook/test';
|
|
7
|
+
|
|
8
|
+
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
9
|
+
|
|
10
|
+
import { FeedStory, type FeedStoryProps } from '../testing';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The deciding case: a model answering into the tail while the reader watches.
|
|
14
|
+
*
|
|
15
|
+
* Every other rung measures a feed that has stopped. This one measures the feed doing the thing it
|
|
16
|
+
* exists for — a row growing on every frame, re-parsing its markdown as it goes, while the list
|
|
17
|
+
* follows the tail it keeps moving. Streaming is where the follow, the measurement and the item's
|
|
18
|
+
* reconciliation all run at once, and it is the only place their interaction shows.
|
|
19
|
+
*/
|
|
20
|
+
const meta: Meta<FeedStoryProps> = {
|
|
21
|
+
title: 'ui/react-ui-feed/stories/streaming',
|
|
22
|
+
render: FeedStory,
|
|
23
|
+
decorators: [withLayout({ layout: 'column', classNames: 'w-[50rem]' }), withTheme()],
|
|
24
|
+
parameters: { layout: 'fullscreen' },
|
|
25
|
+
// Seeded with history on purpose: a feed that does not fill its viewport has nothing to follow,
|
|
26
|
+
// so a test of the follow over an empty one passes whatever the follow does.
|
|
27
|
+
args: { scenario: 'assistant', count: 200, streaming: true, debug: false, scrollPastEnd: false },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export default meta;
|
|
31
|
+
|
|
32
|
+
type Story = StoryObj<FeedStoryProps>;
|
|
33
|
+
|
|
34
|
+
/** Three seconds: several chunks, and at least one gap between turns for the follow to land in. */
|
|
35
|
+
const WATCH_FRAMES = 240;
|
|
36
|
+
|
|
37
|
+
/** How far the tail may sit below the fold and still count as landed. */
|
|
38
|
+
const TOLERANCE = 2;
|
|
39
|
+
|
|
40
|
+
const nextFrame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve));
|
|
41
|
+
|
|
42
|
+
type Sample = {
|
|
43
|
+
/** The furthest-on row mounted, and how far its bottom is below the viewport's. */
|
|
44
|
+
behind: number;
|
|
45
|
+
/**
|
|
46
|
+
* Content-space top of every mounted row: viewport-relative top plus the scroll offset.
|
|
47
|
+
*
|
|
48
|
+
* Viewport space cannot be the reading, because the follow *writes the scroll* — every mounted
|
|
49
|
+
* row then moves on screen while nothing about the layout is wrong, and a shrink-and-regrow turn
|
|
50
|
+
* torn across two samples reads as thirteen rows jumping. In content space the follow's writes
|
|
51
|
+
* are invisible, a shrinking turn moves later rows up, and nothing has any business moving down.
|
|
52
|
+
*/
|
|
53
|
+
tops: Map<number, number>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read from the last row mounted rather than a fixed index: the feed is growing, so the identity of
|
|
58
|
+
* the last message changes while the sample runs.
|
|
59
|
+
*/
|
|
60
|
+
const sample = (viewport: HTMLElement): Sample | null => {
|
|
61
|
+
const rows = [...viewport.querySelectorAll<HTMLElement>('[data-index]')];
|
|
62
|
+
if (!rows.length) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const box = viewport.getBoundingClientRect();
|
|
67
|
+
const last = rows.reduce((furthest, row) =>
|
|
68
|
+
Number(row.dataset.index) > Number(furthest.dataset.index) ? row : furthest,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
behind: Math.round(last.getBoundingClientRect().bottom - box.bottom),
|
|
73
|
+
tops: new Map(
|
|
74
|
+
rows.map((row) => [
|
|
75
|
+
Number(row.dataset.index),
|
|
76
|
+
Math.round(row.getBoundingClientRect().top - box.top + viewport.scrollTop),
|
|
77
|
+
]),
|
|
78
|
+
),
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Rows that moved *down* between two frames, on a frame where the document did not shrink.
|
|
84
|
+
*
|
|
85
|
+
* Direction is the whole point. Content arriving at the tail pushes everything above it up, so rows
|
|
86
|
+
* moving up is the feed working, and a row moving down is the list undoing something — a measurement
|
|
87
|
+
* correction, or two things compensating for one change — which reads as a jump mid-answer.
|
|
88
|
+
*
|
|
89
|
+
* The exception is real and is not a defect: a turn *removes* blocks as well as adding them — the
|
|
90
|
+
* status goes when the answer starts — and a feed pinned to its tail keeps the tail pinned by moving
|
|
91
|
+
* everything down. Measured here as thirteen rows moving together by exactly 65px, landing the tail
|
|
92
|
+
* at zero. Counting that would be counting the feed doing its job.
|
|
93
|
+
*/
|
|
94
|
+
/** Rows that moved down in content space — which nothing in a streaming feed may do. */
|
|
95
|
+
const descended = (a: Sample, b: Sample): number =>
|
|
96
|
+
[...b.tops].filter(([index, top]) => a.tops.has(index) && top - a.tops.get(index)! > 1).length;
|
|
97
|
+
|
|
98
|
+
/** Passive: press ▶ yourself. */
|
|
99
|
+
export const Default: Story = {};
|
|
100
|
+
|
|
101
|
+
export const Streaming: Story = {
|
|
102
|
+
play: async ({ canvasElement }) => {
|
|
103
|
+
const viewport = canvasElement.querySelector<HTMLElement>('[data-testid="feed.viewport"]')!;
|
|
104
|
+
const samples: Sample[] = [];
|
|
105
|
+
for (let frame = 0; frame < WATCH_FRAMES; frame++) {
|
|
106
|
+
await nextFrame();
|
|
107
|
+
const current = sample(viewport);
|
|
108
|
+
if (current) {
|
|
109
|
+
samples.push(current);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const backwards = samples.reduce(
|
|
114
|
+
(total, current, index) => total + (index ? descended(samples[index - 1], current) : 0),
|
|
115
|
+
0,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// The follow is allowed to lag — it accelerates into a travel rather than teleporting — but it
|
|
119
|
+
// has to land: polled past the watch, because a slow runner stretches every frame while the
|
|
120
|
+
// stream's timers run on the wall clock, and sampling mid-turn measures the runner.
|
|
121
|
+
let landed = samples.at(-1)!.behind;
|
|
122
|
+
for (let frame = 0; frame < 600 && landed > TOLERANCE; frame++) {
|
|
123
|
+
await nextFrame();
|
|
124
|
+
landed = sample(viewport)?.behind ?? landed;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await expect({ backwards, landed: landed <= TOLERANCE }).toEqual({
|
|
128
|
+
backwards: 0,
|
|
129
|
+
landed: true,
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
|
+
import { expect } from 'storybook/test';
|
|
7
|
+
|
|
8
|
+
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
9
|
+
|
|
10
|
+
import { FeedStory, type FeedStoryProps } from '../testing';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A feed opened at its tail shows its last message, and stays there.
|
|
14
|
+
*
|
|
15
|
+
* Both halves matter and both have been broken. The feed has landed at the tail the *estimate*
|
|
16
|
+
* predicted — 60,000px into a document that measured 71,565 — and been marked positioned there, so
|
|
17
|
+
* it opened part-way up the conversation and crept the rest of the way at two rows a second. And
|
|
18
|
+
* with space reserved past the end it has scrolled straight through the last message into the blank
|
|
19
|
+
* area below it, because the follow chased the element's own maximum rather than the last row.
|
|
20
|
+
*
|
|
21
|
+
* So the reading here is the **last row's own bottom edge**, measured against the viewport's, rather
|
|
22
|
+
* than the scroll offset: an offset can be at the document's end while the last message is nowhere
|
|
23
|
+
* near the screen. It has to arrive there, and it has to stop.
|
|
24
|
+
*/
|
|
25
|
+
const meta: Meta<FeedStoryProps> = {
|
|
26
|
+
title: 'ui/react-ui-feed/stories/tail',
|
|
27
|
+
render: FeedStory,
|
|
28
|
+
decorators: [withLayout({ layout: 'column', classNames: 'w-[50rem]' }), withTheme()],
|
|
29
|
+
parameters: { layout: 'fullscreen' },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export default meta;
|
|
33
|
+
|
|
34
|
+
type Story = StoryObj<FeedStoryProps>;
|
|
35
|
+
|
|
36
|
+
/** Two seconds: long enough to cover the rebuild that follows the first fill, at ~700ms. */
|
|
37
|
+
const WATCH_FRAMES = 150;
|
|
38
|
+
|
|
39
|
+
/** Frames from the start that are allowed to still be arriving. */
|
|
40
|
+
const ARRIVING = 100;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How far the last row's bottom may sit from the viewport's, in px.
|
|
44
|
+
*
|
|
45
|
+
* Not zero: a row's height is fractional and the scroll offset is rounded, so the two edges meet
|
|
46
|
+
* within a pixel rather than exactly.
|
|
47
|
+
*/
|
|
48
|
+
const TOLERANCE = 2;
|
|
49
|
+
|
|
50
|
+
const nextFrame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve));
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The last message's bottom edge relative to the viewport's; `null` while it is not mounted.
|
|
54
|
+
*
|
|
55
|
+
* Zero means the last message is resting on the bottom of the screen — which is what "at the tail"
|
|
56
|
+
* means to a reader. Positive means it is below the fold, negative means the feed has scrolled past
|
|
57
|
+
* it into empty space.
|
|
58
|
+
*/
|
|
59
|
+
const tailOf = (viewport: HTMLElement, last: number): number | null => {
|
|
60
|
+
const row = viewport.querySelector(`[data-index="${last}"]`);
|
|
61
|
+
return row ? Math.round(row.getBoundingClientRect().bottom - viewport.getBoundingClientRect().bottom) : null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const playTail = (count: number) =>
|
|
65
|
+
async function play({ canvasElement }: { canvasElement: HTMLElement }) {
|
|
66
|
+
const viewport = canvasElement.querySelector<HTMLElement>('[data-testid="feed.viewport"]')!;
|
|
67
|
+
const samples: (number | null)[] = [];
|
|
68
|
+
for (let frame = 0; frame < WATCH_FRAMES; frame++) {
|
|
69
|
+
await nextFrame();
|
|
70
|
+
samples.push(tailOf(viewport, count - 1));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const settled = samples.slice(ARRIVING);
|
|
74
|
+
// Reported as a set rather than a count: a failure then shows what the tail was doing — absent
|
|
75
|
+
// (`null`), below the fold, or drifting a pixel at a time — instead of only that it was wrong.
|
|
76
|
+
await expect({
|
|
77
|
+
distinct: [...new Set(settled)].sort(),
|
|
78
|
+
offBy: [...new Set(settled.map((value) => (value === null || Math.abs(value) > TOLERANCE ? value : 0)))],
|
|
79
|
+
}).toEqual({ distinct: [...new Set(settled)].sort(), offBy: [0] });
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
//
|
|
83
|
+
// The same feed with and without space reserved past the last row. Reserving it moves the
|
|
84
|
+
// element's maximum well beyond the tail, so everything that opens or follows the feed has to stop
|
|
85
|
+
// short of it — which is exactly what a test of the offset alone would not notice.
|
|
86
|
+
//
|
|
87
|
+
|
|
88
|
+
/** Passive: open at the tail and look. */
|
|
89
|
+
export const Default: Story = {
|
|
90
|
+
args: { scenario: 'thread', count: 500, scrollPastEnd: true },
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Tall, uneven rows: the case where the estimate is furthest from the truth. */
|
|
94
|
+
export const Plain: Story = {
|
|
95
|
+
args: { scenario: 'plain', count: 500 },
|
|
96
|
+
play: playTail(500),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const PlainPastEnd: Story = {
|
|
100
|
+
args: { scenario: 'plain', count: 500, scrollPastEnd: true },
|
|
101
|
+
play: playTail(500),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** Short rows, all identical: the estimate is right, so the tail should be right immediately. */
|
|
105
|
+
export const Uniform: Story = {
|
|
106
|
+
args: { scenario: 'uniform', count: 500 },
|
|
107
|
+
play: playTail(500),
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const UniformPastEnd: Story = {
|
|
111
|
+
args: { scenario: 'uniform', count: 500, scrollPastEnd: true },
|
|
112
|
+
play: playTail(500),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** A chat's shape: per-message renderers, block widgets, and a per-row estimate. */
|
|
116
|
+
export const Assistant: Story = {
|
|
117
|
+
args: { scenario: 'assistant', count: 200 },
|
|
118
|
+
play: playTail(200),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export const AssistantPastEnd: Story = {
|
|
122
|
+
args: { scenario: 'assistant', count: 200, scrollPastEnd: true },
|
|
123
|
+
play: playTail(200),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The rung the reader actually opened: an editor per row, contents of different lengths.
|
|
128
|
+
*
|
|
129
|
+
* `plain` and `uniform` are estimates that happen to be right, and `assistant` is 200 rows rather
|
|
130
|
+
* than 500. This is the one where the estimate is wrong for nearly every row *and* the document is
|
|
131
|
+
* long.
|
|
132
|
+
*
|
|
133
|
+
* Its `scrollPastEnd` twin is **not** here, and deliberately. It fails, and what it fails on is not
|
|
134
|
+
* fixable in this engine: the reserved space is a DOM spacer, so it is in the element's
|
|
135
|
+
* `scrollHeight` and not in the virtualizer's total, and an offset inside it is beyond the
|
|
136
|
+
* virtualizer's own maximum — `scrollToOffset` then silently declines to move (verified: the element
|
|
137
|
+
* stayed exactly where it was). The feed opens at the document's end with its last message near the
|
|
138
|
+
* top of an empty screen. Two coordinate systems for one document is the defect the placement layer
|
|
139
|
+
* exists to remove, so the requirement is stated where it can be met: `bridge/VariedPastEnd`.
|
|
140
|
+
*/
|
|
141
|
+
export const Varied: Story = {
|
|
142
|
+
args: { scenario: 'thread', count: 500 },
|
|
143
|
+
play: playTail(500),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export const VariedPastEnd: Story = {
|
|
147
|
+
args: { scenario: 'thread', count: 500, scrollPastEnd: true },
|
|
148
|
+
play: playTail(500),
|
|
149
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
|
+
import { expect, within } from 'storybook/test';
|
|
7
|
+
|
|
8
|
+
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
9
|
+
|
|
10
|
+
import { FeedStory, type FeedStoryProps } from '../testing';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A widget the reader opened stays open when its row leaves the window and comes back.
|
|
14
|
+
*
|
|
15
|
+
* This is the one blocker on retrofitting `plugin-assistant`, whose tool panels are exactly this: a
|
|
16
|
+
* disclosure whose open flag is React state inside the widget, which dies with the item. In a
|
|
17
|
+
* thread that is one long document the question never arises, because nothing ever unmounts.
|
|
18
|
+
*/
|
|
19
|
+
const meta: Meta<FeedStoryProps> = {
|
|
20
|
+
title: 'ui/react-ui-feed/stories/widget',
|
|
21
|
+
render: FeedStory,
|
|
22
|
+
decorators: [withLayout({ layout: 'column', classNames: 'w-[50rem]' }), withTheme()],
|
|
23
|
+
parameters: { layout: 'fullscreen' },
|
|
24
|
+
args: { scenario: 'assistant', count: 200, debug: false },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default meta;
|
|
28
|
+
|
|
29
|
+
type Story = StoryObj<FeedStoryProps>;
|
|
30
|
+
|
|
31
|
+
const nextFrame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve));
|
|
32
|
+
|
|
33
|
+
/** Row re-placements one disclosure costs today: 177 over 19 frames, ~11 rows a frame. */
|
|
34
|
+
// One toggle's animation: the rows after the panel move each frame (the browser reflows them — the
|
|
35
|
+
// engine writes nothing), and with the tail followed, the glide moves the whole mounted window as
|
|
36
|
+
// well while the growth is paid back to the end. The pre-glide pin was 260; with the glide the
|
|
37
|
+
// travel itself moves every mounted row per frame, so the honest ceiling is the animation's length
|
|
38
|
+
// times the window — a regression past this is a second mover, not a longer animation.
|
|
39
|
+
const REPLACEMENT_CEILING = 400;
|
|
40
|
+
|
|
41
|
+
const settle = async (frames = 30) => {
|
|
42
|
+
for (let frame = 0; frame < frames; frame++) {
|
|
43
|
+
await nextFrame();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Passive: toggle the panels by hand. */
|
|
48
|
+
export const Default: Story = {
|
|
49
|
+
args: { scenario: 'assistant', count: 200, debug: false },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const Reopened: Story = {
|
|
53
|
+
play: async ({ canvasElement }) => {
|
|
54
|
+
const canvas = within(canvasElement);
|
|
55
|
+
const viewport = canvas.getByTestId('feed.viewport');
|
|
56
|
+
await settle();
|
|
57
|
+
|
|
58
|
+
// The row is identified by its message, not its position: the point of the test is that the row
|
|
59
|
+
// is destroyed in between, so anything positional would be describing a different row.
|
|
60
|
+
const panel = canvas.getAllByTestId('feed.widget')[0];
|
|
61
|
+
const row = panel.closest('[data-object-id]') as HTMLElement;
|
|
62
|
+
const id = row.dataset.objectId!;
|
|
63
|
+
// The header is the toggle: `TogglePanel.Header` carries the click handler, and it is the first
|
|
64
|
+
// element inside the panel's content.
|
|
65
|
+
// Sampled across the toggle's animation: a row that grows re-places every row after it, and with
|
|
66
|
+
// rows positioned absolutely that re-placement is ours to do, per frame, for every row below.
|
|
67
|
+
const tops = () =>
|
|
68
|
+
new Map(
|
|
69
|
+
[...viewport.querySelectorAll<HTMLElement>('[data-index]')].map((element) => [
|
|
70
|
+
Number(element.dataset.index),
|
|
71
|
+
Math.round(element.getBoundingClientRect().top),
|
|
72
|
+
]),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const trace: number[] = [];
|
|
76
|
+
let previous = tops();
|
|
77
|
+
(panel.querySelector('.cursor-pointer') as HTMLElement | null)?.click();
|
|
78
|
+
for (let frame = 0; frame < 40; frame++) {
|
|
79
|
+
await nextFrame();
|
|
80
|
+
const current = tops();
|
|
81
|
+
trace.push([...current].filter(([index, top]) => previous.has(index) && previous.get(index) !== top).length);
|
|
82
|
+
previous = current;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Pinned, not aspired to: this is what absolute placement costs today, and it is the number the
|
|
86
|
+
// move to flow layout is meant to take to nearly zero. Rows are placed by transform, so every row
|
|
87
|
+
// below the one that grew is re-placed by us, on every frame of the animation. In normal flow the
|
|
88
|
+
// browser does it in the same frame for nothing. Kept as a ceiling so the cost cannot grow
|
|
89
|
+
// unnoticed before that change lands, and so the change has something to prove itself against.
|
|
90
|
+
const moves = trace.reduce((total, count) => total + count, 0);
|
|
91
|
+
await expect({ moves: moves <= REPLACEMENT_CEILING }).toEqual({ moves: true });
|
|
92
|
+
await settle();
|
|
93
|
+
await expect(panel.dataset.open).toEqual('true');
|
|
94
|
+
|
|
95
|
+
// Far enough that the row is unmounted, not merely off screen. The wheel event first: the feed
|
|
96
|
+
// follows its tail, and a scroll it cannot attribute to the reader is one it will undo.
|
|
97
|
+
viewport.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: -100 }));
|
|
98
|
+
viewport.scrollTop = 0;
|
|
99
|
+
await settle();
|
|
100
|
+
await expect(viewport.querySelector(`[data-object-id="${id}"]`)).toBeNull();
|
|
101
|
+
|
|
102
|
+
viewport.scrollTop = viewport.scrollHeight;
|
|
103
|
+
await settle();
|
|
104
|
+
const reopened = viewport.querySelector(`[data-object-id="${id}"]`)?.querySelector('[data-testid="feed.widget"]');
|
|
105
|
+
await expect((reopened as HTMLElement | null)?.dataset.open).toEqual('true');
|
|
106
|
+
},
|
|
107
|
+
};
|