@miso.ai/client-sdk-ui 1.14.0-beta.2 → 1.14.0-beta.4

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 (54) hide show
  1. package/dist/cjs/asset/svgs.js +4 -2
  2. package/dist/cjs/constants.js +2 -0
  3. package/dist/cjs/defaults/ask/index.js +2 -1
  4. package/dist/cjs/defaults/ask/templates.js +2 -1
  5. package/dist/cjs/defaults/hybrid-search/index.js +2 -1
  6. package/dist/cjs/defaults/hybrid-search/templates.js +2 -1
  7. package/dist/cjs/defaults/index.js +2 -1
  8. package/dist/cjs/defaults.js +107 -10
  9. package/dist/cjs/element/container/index.js +14 -0
  10. package/dist/cjs/element/container/miso-message-item.js +91 -0
  11. package/dist/cjs/element/container/miso-thread-item.js +91 -0
  12. package/dist/cjs/element/index.js +2 -1
  13. package/dist/cjs/element/role/index.js +7 -0
  14. package/dist/cjs/element/role/miso-delete.js +20 -0
  15. package/dist/cjs/layout/button/button.js +45 -5
  16. package/dist/cjs/layout/button/checkbox.js +2 -1
  17. package/dist/cjs/layout/container/item-container.js +99 -0
  18. package/dist/cjs/layout/index.js +14 -0
  19. package/dist/cjs/layout/input/search-box.js +9 -4
  20. package/dist/cjs/layout/list/collection.js +21 -4
  21. package/dist/cjs/layout/list/messages.js +261 -224
  22. package/dist/cjs/layout/list/threads.js +106 -150
  23. package/dist/cjs/layout/templates.js +98 -38
  24. package/dist/cjs/layout/text/question.js +71 -0
  25. package/dist/cjs/layout/text/typewriter/index.js +15 -21
  26. package/dist/cjs/plugin.js +6 -3
  27. package/dist/cjs/util/dom.js +21 -0
  28. package/dist/cjs/util/notify.js +74 -0
  29. package/dist/cjs/version.js +1 -1
  30. package/package.json +3 -3
  31. package/src/CLAUDE.md +7 -2
  32. package/src/asset/svgs.js +3 -1
  33. package/src/constants.js +2 -0
  34. package/src/defaults.js +75 -5
  35. package/src/element/container/index.js +2 -0
  36. package/src/element/container/miso-message-item.js +94 -0
  37. package/src/element/container/miso-thread-item.js +94 -0
  38. package/src/element/role/index.js +1 -0
  39. package/src/element/role/miso-delete.js +16 -0
  40. package/src/layout/button/button.js +40 -4
  41. package/src/layout/button/checkbox.js +2 -1
  42. package/src/layout/container/item-container.js +99 -0
  43. package/src/layout/index.js +2 -0
  44. package/src/layout/input/search-box.js +4 -2
  45. package/src/layout/list/collection.js +22 -5
  46. package/src/layout/list/messages.js +231 -201
  47. package/src/layout/list/threads.js +107 -128
  48. package/src/layout/templates.js +100 -38
  49. package/src/layout/text/question.js +58 -0
  50. package/src/layout/text/typewriter/index.js +11 -15
  51. package/src/plugin.js +3 -2
  52. package/src/util/dom.js +16 -0
  53. package/src/util/notify.js +68 -0
  54. package/src/version.js +1 -1
@@ -4,34 +4,71 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.default = void 0;
7
- var _commons = require("@miso.ai/commons");
8
- var _clientSdkWorkflow = require("@miso.ai/client-sdk-workflow");
9
7
  var _constants = require("../../constants.js");
10
- var _collection = _interopRequireDefault(require("./collection.js"));
11
- var _utils = require("../text/typewriter/utils.js");
8
+ var _collection = _interopRequireWildcard(require("./collection.js"));
12
9
  var _templates = require("../templates.js");
13
10
  var _dom = require("../../util/dom.js");
14
- const _excluded = ["className"];
15
- function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
11
+ const _excluded = ["className", "templates"];
12
+ var cache;
13
+ function _interopRequireWildcard(e, t) { if (!t && e && e.__esModule) return e; var r, c, a = Object.defineProperty, n = { __proto__: null, default: e }; if (Object(e) !== e) return n; if (cache || "function" != typeof WeakMap || (cache = new WeakMap()), cache) { if (cache.has(e)) return cache.get(e); cache.set(e, n); } for (c in e) "default" !== c && {}.hasOwnProperty.call(e, c) && ((r = a && Object.getOwnPropertyDescriptor(e, c)) && (r.get || r.set) ? a(n, c, r) : n[c] = e[c]); return n; }
16
14
  function _objectWithoutPropertiesLoose(r, e) { if (null == r) return {}; var t = {}; for (var n in r) if ({}.hasOwnProperty.call(r, n)) { if (-1 !== e.indexOf(n)) continue; t[n] = r[n]; } return t; }
17
15
  const TYPE = _constants.LAYOUT_TYPE.MESSAGES;
18
16
  const DEFAULT_CLASSNAME = 'miso-messages';
19
- const TYPEWRITER_CLASSNAME = 'miso-typewriter';
17
+ const DEFAULT_TEMPLATES = Object.freeze({
18
+ root,
19
+ [_constants.STATUS.ERRONEOUS]: _templates.listError,
20
+ errorMessage
21
+ });
22
+ const INHERITED_DEFAULT_TEMPLATES = Object.freeze(Object.assign({}, _collection.default.defaultTemplates, DEFAULT_TEMPLATES));
23
+
24
+ // the infinite scroll trigger sits ABOVE the list — older messages load as
25
+ // the user scrolls up, unlike the stock collection root's bottom trigger
26
+ function root(layout, state) {
27
+ const {
28
+ className,
29
+ role,
30
+ templates,
31
+ options
32
+ } = layout;
33
+ const {
34
+ status
35
+ } = state;
36
+ const roleAttr = role ? ` data-role="${role}"` : '';
37
+ const itemTypeAttr = options.itemType ? ` data-item-type="${options.itemType}"` : '';
38
+ return `<div class="${className} ${status}"${roleAttr}${itemTypeAttr}>${templates.trigger(layout, state)}${(0, _collection.hasStatusTemplate)(status) ? templates[status](layout, state) : ''}${templates.loading(layout, state)}</div>`;
39
+ }
40
+ function errorMessage(layout, error) {
41
+ return (0, _templates.isSessionExpired)(error) ? _templates.SESSION_EXPIRED_MESSAGE : 'This conversation could not be loaded.';
42
+ }
20
43
 
21
44
  /**
22
- * The conversation panel of the chat history interface: a list of `message`
23
- * items (question bubble + answer body), rendered incrementally — appended
24
- * messages (e.g. a posted follow-up question) render as new items, while
25
- * in-place changes are applied to existing items by a post-render pass
26
- * (`_syncMessages`):
45
+ * The conversation panel of the chat history interface: a shell that renders
46
+ * one <miso-message-item> container element per message item, incrementally —
47
+ * fresh items land at either end of the list: appended messages (a posted
48
+ * follow-up question) render as new items at the bottom, while an older
49
+ * page fetched by the more flow (infinite scroll: the trigger, placed above
50
+ * the list, fires the `more` hub field as the user scrolls up) renders as
51
+ * prepended items — told apart by which boundary the already-rendered
52
+ * records line up with.
53
+ * The content of a message is not rendered here: each <miso-message-item> is
54
+ * assigned its item subworkflow (workflow._getMessageWorkflow, off the item
55
+ * binding) in the post-render sync pass, and the role elements inside
56
+ * (question, answer, ...) render through that workflow's own layouts.
57
+ *
58
+ * All message-level presentation is the message workflow's own: a record
59
+ * without its answer body presents as status `loading`, stamped on the
60
+ * <miso-message-item> element by its container layout — there is deliberately no
61
+ * per-message loading icon; the answer typewriter's blinking caret is the
62
+ * loading indication — and the question bubble (text, authorship
63
+ * attributes) renders through the `question` layout.
27
64
  *
28
- * - question texts are filled in when they arrive (the head request carries
29
- * question ids only)
30
- * - a finished answer body is transformed from markdown to HTML in one shot
31
- * through the `std:ui-markdown` plugin
32
- * - a streaming answer (a follow-up being generated, `finished: false`) is
33
- * driven by a per-message typewriter: a progressive markdown renderer with
34
- * a paced cursor, like the typewriter layout of the ask workflow
65
+ * The shell keeps the panel-level behaviors: scroll pinning (the view sticks
66
+ * to the bottom while the user has not scrolled up, following content growth
67
+ * as answers type out), scroll anchoring while scrolled up (prepended items
68
+ * — and their contents, rendering in later through their own workflows —
69
+ * must not shift the reading position), and the `ongoing` view state read
70
+ * off the data — the last message's answer absent or unfinished — which
71
+ * e.g. disables the search box while an answer is on its way.
35
72
  */
36
73
  class MessagesLayout extends _collection.default {
37
74
  static get type() {
@@ -40,174 +77,171 @@ class MessagesLayout extends _collection.default {
40
77
  static get defaultClassName() {
41
78
  return DEFAULT_CLASSNAME;
42
79
  }
80
+ static get defaultTemplates() {
81
+ return INHERITED_DEFAULT_TEMPLATES;
82
+ }
43
83
  constructor(_ref = {}) {
44
84
  let {
45
- className = DEFAULT_CLASSNAME
85
+ className = DEFAULT_CLASSNAME,
86
+ templates
46
87
  } = _ref,
47
88
  options = _objectWithoutPropertiesLoose(_ref, _excluded);
48
89
  super(Object.assign({
49
- className
90
+ className,
91
+ templates: Object.assign({}, DEFAULT_TEMPLATES, templates)
50
92
  }, options));
51
- this._renderedAnswers = new WeakMap(); // answer element -> rendered markdown
52
- this._typewriters = new WeakMap(); // answer element -> MessageTypewriter
53
- this._readiness = new _commons.Resolution();
54
93
  this._pinned = true; // whether the view sticks to the bottom on updates
55
- this._displaying = false; // whether a typewriter is still displaying an answer
56
- // kick off sooner
57
- MessagesLayout.MisoClient.plugins.install('std:ui-markdown');
58
- }
59
- initialize(view) {
60
- super.initialize(view);
61
- this._setup();
94
+ this._anchor = undefined; // the scroll anchor while prepended content settles
95
+ this._ongoing = undefined;
62
96
  }
63
97
 
64
- // setup //
65
- async _setup() {
66
- try {
67
- const plugin = await MessagesLayout.MisoClient.plugins.install('std:ui-markdown');
68
- if (!this._view) {
69
- return; // destroyed
70
- }
71
- this._markdown = plugin.getContext(this._view.workflow._client);
72
- this._readiness.resolve();
73
- } catch (e) {
74
- this._readiness.reject(e);
75
- }
76
- }
77
- async _ready() {
78
- return this._readiness.promise;
98
+ // item identity: the question id, or the local placeholder id standing in
99
+ // for it while a posted question awaits its response — the placeholder id
100
+ // survives the settle, so the element and its workflow do too
101
+ _getItemKey(message) {
102
+ return message.placeholder_id || message.question_id || message;
79
103
  }
80
104
 
81
105
  // render //
82
106
  async render(element, state, controls = {}) {
83
- // capture the view state callback, so the typewriter can report the
84
- // `ongoing` state outside the render cycle
107
+ // capture the view state callback for the ongoing report
85
108
  this._notifyUpdate = controls.notifyUpdate;
86
109
  await super.render(element, state, controls);
87
110
  }
111
+
112
+ // incremental renders cover a pure append (a posted follow-up at the
113
+ // bottom) or a pure prepend (an older page above) — anything else
114
+ // re-renders in full
115
+ _shallRenderIncrementally(state, rendered) {
116
+ if (!super._shallRenderIncrementally(state, rendered)) {
117
+ return false;
118
+ }
119
+ const values = this._getItems(state) || [];
120
+ const renderedValues = rendered.value;
121
+ const count = values.length - renderedValues.length;
122
+ return count === 0 ||
123
+ // in-place changes only
124
+ this._getItemKey(values[0]) === this._getItemKey(renderedValues[0]) ||
125
+ // appended
126
+ this._getItemKey(values[count]) === this._getItemKey(renderedValues[0]); // prepended
127
+ }
128
+
129
+ // the fresh items land at the tail (a posted follow-up) or at the head
130
+ // (an older page fetched by the more flow): told apart by which boundary
131
+ // the already-rendered records line up with
132
+ _html(state, rendered, incremental) {
133
+ if (!incremental) {
134
+ return this.templates.root(this, state);
135
+ }
136
+ const values = this._getItems(state) || [];
137
+ const renderedValues = rendered.value;
138
+ const count = values.length - renderedValues.length;
139
+ if (count === 0) {
140
+ return '';
141
+ }
142
+ if (this._getItemKey(values[0]) === this._getItemKey(renderedValues[0])) {
143
+ const fresh = values.slice(renderedValues.length);
144
+ return {
145
+ position: 'beforeend',
146
+ items: this.templates.items(this, state, fresh, {
147
+ offset: renderedValues.length
148
+ })
149
+ };
150
+ }
151
+ const fresh = values.slice(0, count);
152
+ return {
153
+ position: 'afterbegin',
154
+ items: this.templates.items(this, state, fresh, {
155
+ offset: 0
156
+ })
157
+ };
158
+ }
159
+ _render(element, {
160
+ state
161
+ }, {
162
+ notifyUpdate
163
+ }) {
164
+ const {
165
+ incremental,
166
+ html
167
+ } = state;
168
+ if (incremental) {
169
+ if (html) {
170
+ const listElement = this._getListElement(element);
171
+ if (html.position === 'afterbegin') {
172
+ this._prependAnchored(listElement, html.items);
173
+ } else {
174
+ listElement.insertAdjacentHTML('beforeend', html.items);
175
+ }
176
+ } else {
177
+ notifyUpdate(false);
178
+ }
179
+ } else {
180
+ element.innerHTML = html;
181
+ }
182
+ }
88
183
  _afterRender(element, state) {
89
- super._afterRender(element, state); // syncs bindings to the latest values
184
+ super._afterRender(element, state); // syncs bindings and (re)arms the trigger
90
185
  if (!state.incremental) {
91
- this._displaying = false; // a fresh render (thread load) drops any ongoing typewriter
92
- }
93
- if (!state.incremental || state.html) {
94
- // a fresh render (thread load) or appended items (posted follow-up):
95
- // jump to the bottom
186
+ // a fresh render (thread load): jump to the bottom
187
+ this._dropAnchor();
96
188
  this._scrollToBottom({
97
189
  force: true
98
190
  });
99
- }
100
- this._syncMessages(element).catch(error => console.error(error));
101
- }
102
- async _syncMessages(element) {
103
- await this._ready();
104
- const items = this._getItemElements(element);
105
- let displaying = false;
106
- for (let i = 0; i < items.length; i++) {
107
- const item = items[i];
108
- const binding = this._bindings.get(item);
109
- if (!binding) {
110
- continue;
111
- }
112
- const message = binding.value;
113
- const last = i === items.length - 1;
114
- this._syncQuestion(item, message);
115
- this._syncAnswer(item, message, {
116
- last
191
+ } else if (state.html && state.html.position === 'beforeend') {
192
+ // appended items (posted follow-up): jump to the bottom
193
+ this._scrollToBottom({
194
+ force: true
117
195
  });
118
- if (last) {
119
- displaying = this._isDisplaying(item, message);
120
- }
121
196
  }
122
- this._setDisplaying(displaying);
197
+ this._watchGrowth(this._getListElement(element) || element);
198
+ this._syncWorkflows(element);
199
+ this._syncItems(element);
200
+ this._syncOngoing(state);
123
201
  }
124
-
125
- // whether the (last) message pair is still being displayed: its answer is
126
- // pending, streaming, or still being typed — live (posted in this session)
127
- // or not: an answer still generating is picked up by the answers polling
128
- // even after switching away from the thread and back, and must keep
129
- // blocking submission all the same
130
- _isDisplaying(item, message) {
131
- if (!(0, _clientSdkWorkflow.hasAnswer)(message)) {
132
- return true; // waiting for the answer body
133
- }
134
- const answerElement = item.querySelector('[data-role="answer"]');
135
- const typewriter = answerElement && this._typewriters.get(answerElement);
136
- return typewriter ? !typewriter.done : message.finished === false;
202
+ _unrender() {
203
+ super._unrender();
204
+ this._dropAnchor();
137
205
  }
138
- _syncQuestion(item, message) {
139
- const questionElement = item.querySelector('[data-role="question"]');
140
- if (!questionElement) {
206
+
207
+ // assign each <miso-message-item> its item subworkflow, off the item binding
208
+ _syncWorkflows(element) {
209
+ const workflow = this._view && this._view.workflow;
210
+ if (!workflow || typeof workflow._getMessageWorkflow !== 'function') {
141
211
  return;
142
212
  }
143
- // the authorship arrives with the answers response, after the stub render
144
- (0, _dom.setOrRemoveAttribute)(questionElement, 'data-author', (0, _templates.messageAuthor)(message));
145
- (0, _dom.setOrRemoveAttribute)(questionElement, 'data-generated-by', (0, _clientSdkWorkflow.getGeneratedBy)(message));
146
- const {
147
- question
148
- } = message;
149
- if (!question || questionElement.textContent === question) {
150
- return;
213
+ for (const item of this._getItemElements(element)) {
214
+ const binding = this._bindings.get(item);
215
+ if (binding && item.isContainer) {
216
+ item.workflow = workflow._getMessageWorkflow(binding.value);
217
+ }
151
218
  }
152
- questionElement.textContent = question;
153
- questionElement.hidden = false;
154
219
  }
155
- _syncAnswer(item, message, {
156
- last = false
157
- } = {}) {
158
- const answerElement = item.querySelector('[data-role="answer"]');
159
- if (!answerElement) {
160
- return;
161
- }
162
- // an ongoing typewriter keeps consuming updates until it finishes typing
163
- const typewriter = this._typewriters.get(answerElement);
164
- if (typewriter) {
165
- typewriter.update(message);
166
- return;
167
- }
168
- if (!(0, _clientSdkWorkflow.hasAnswer)(message)) {
169
- return; // still loading; the spinner stays
170
- }
171
- if (message.live || message.finished === false) {
172
- // an answer being generated live in this session: drive it with a
173
- // typewriter (even if the data arrived complete in one shot)
174
- const typewriter = new MessageTypewriter(this._markdown, answerElement, {
175
- onUpdate: () => this._scrollToBottom(),
176
- // keep pinned to the bottom while typing
177
- onDone: () => this._setDisplaying(false)
178
- });
179
- this._typewriters.set(answerElement, typewriter);
180
- typewriter.update(message);
181
- return;
182
- }
183
- // a finished answer: transform in one shot
184
- if (this._renderedAnswers.get(answerElement) === message.answer) {
185
- return; // already rendered
220
+
221
+ // sync in-place item state changes from the bound values onto the existing
222
+ // item elements: the live mark — the item contents render through the
223
+ // message workflows, outside this layout's render cycle
224
+ _syncItems(element) {
225
+ for (const item of this._getItemElements(element)) {
226
+ const binding = this._bindings.get(item);
227
+ binding && (0, _dom.setOrRemoveAttribute)(item, 'data-live', binding.value.live ? '' : undefined);
186
228
  }
187
- this._renderedAnswers.set(answerElement, message.answer);
188
- this._transform(answerElement, message).then(() => {
189
- last && this._scrollToBottom();
190
- }).catch(error => {
191
- this._renderedAnswers.delete(answerElement);
192
- console.error(error);
193
- });
194
- }
195
- async _transform(answerElement, {
196
- answer,
197
- sources
198
- }) {
199
- answerElement.innerHTML = await this._markdown.transform(answer, sources);
200
229
  }
201
230
 
202
- // whether the last answer is still being displayed (typed); reported as
203
- // the `ongoing` view state, e.g. for the search box to disable submission
204
- _setDisplaying(displaying) {
205
- if (this._displaying === displaying) {
231
+ // whether an answer is on its way — pending, or still being generated —
232
+ // reported as the `ongoing` view state, e.g. for the search box to disable
233
+ // submission
234
+ _syncOngoing(state) {
235
+ const messages = state.value || [];
236
+ const last = messages[messages.length - 1];
237
+ // a message settled by a failed content fetch is not on its way anymore
238
+ const ongoing = !!last && !last.error && (last.answer === undefined || last.finished === false);
239
+ if (this._ongoing === ongoing) {
206
240
  return;
207
241
  }
208
- this._displaying = displaying;
242
+ this._ongoing = ongoing;
209
243
  this._notifyUpdate && this._notifyUpdate({
210
- ongoing: displaying
244
+ ongoing
211
245
  });
212
246
  }
213
247
 
@@ -250,6 +284,14 @@ class MessagesLayout extends _collection.default {
250
284
  this._unwatchScroll && this._unwatchScroll();
251
285
  const onScroll = () => {
252
286
  this._pinned = scrollable.scrollHeight - scrollable.scrollTop - scrollable.clientHeight < 40;
287
+ if (this._pinned) {
288
+ // back at the bottom: the reading position needs no anchor
289
+ this._anchor = undefined;
290
+ } else if (this._anchor && this._anchor.element.isConnected) {
291
+ // follow the user: the anchor holds the position they scrolled to,
292
+ // not the one where the prepend happened
293
+ this._anchor.top = this._relativeTop(this._anchor.element, scrollable);
294
+ }
253
295
  };
254
296
  scrollable.addEventListener('scroll', onScroll, {
255
297
  passive: true
@@ -262,85 +304,80 @@ class MessagesLayout extends _collection.default {
262
304
  };
263
305
  this._unsubscribes.push(() => this._unwatchScroll === unwatch && unwatch());
264
306
  }
265
- }
266
307
 
267
- // TODO: can't we just use Controller?
268
- /**
269
- * Types a streaming answer into an element: a progressive markdown renderer
270
- * fed by data updates, advanced by a paced cursor on animation frames — the
271
- * typewriting effect, without the full typewriter layout machinery.
272
- */
273
- exports.default = MessagesLayout;
274
- class MessageTypewriter {
275
- constructor(markdown, element, {
276
- onUpdate,
277
- onDone
278
- } = {}) {
279
- this._element = element;
280
- this._onUpdate = onUpdate;
281
- this._onDone = onDone;
282
- // the element acts as a typewriter container: it starts as the caret ref
283
- // (the cursor class moves into the content as it types), and the preset
284
- // stamps the `done` class on finish, which hides the caret via CSS
285
- element.classList.add(TYPEWRITER_CLASSNAME, (0, _utils.cursorClassName)(TYPEWRITER_CLASSNAME));
286
- this._renderer = markdown.createRenderer({
287
- cursorClass: (0, _utils.cursorClassName)(TYPEWRITER_CLASSNAME),
288
- getSource: index => (this._sources || [])[index]
289
- });
290
- this._getNextCursor = (0, _commons.pacer)();
291
- this._rendered = this._renderer.clear(element);
292
- this._timestamp = undefined;
293
- this._value = '';
294
- this._sources = undefined;
295
- this._dataDone = false;
296
- this._doneAt = undefined;
297
- this._requested = false;
308
+ // the message contents render (and type out) through their own workflows,
309
+ // outside this layout's render cycle: follow the content growth — pinned,
310
+ // the view sticks to the bottom; scrolled up with an anchor active (a
311
+ // prepended page still rendering its contents in), the anchor is held
312
+ // stationary instead
313
+ _watchGrowth(element) {
314
+ if (!element || this._watchedGrowth === element || typeof ResizeObserver === 'undefined') {
315
+ return;
316
+ }
317
+ this._unwatchGrowth && this._unwatchGrowth();
318
+ const observer = new ResizeObserver(() => this._onContentGrowth());
319
+ observer.observe(element);
320
+ this._watchedGrowth = element;
321
+ const unwatch = this._unwatchGrowth = () => {
322
+ observer.disconnect();
323
+ this._watchedGrowth = undefined;
324
+ this._unwatchGrowth = undefined;
325
+ };
326
+ this._unsubscribes.push(() => this._unwatchGrowth === unwatch && unwatch());
298
327
  }
299
- get done() {
300
- return this._rendered.done;
328
+ _onContentGrowth() {
329
+ if (this._pinned) {
330
+ this._scrollToBottom();
331
+ } else {
332
+ this._restoreAnchor();
333
+ }
301
334
  }
302
- update({
303
- answer = '',
304
- finished,
305
- sources
306
- }) {
307
- this._value = answer;
308
- this._sources = sources;
309
- this._dataDone = finished !== false;
310
- this._requestFrame();
335
+
336
+ // scroll anchoring for prepended content //
337
+ // The anchor is the previously-first item: everything prepended — and
338
+ // its content, rendering in later through the item workflows — sits
339
+ // above it, so holding the anchor stationary holds the reading position,
340
+ // while growth below it (a streaming answer at the bottom) moves nothing
341
+ // above and needs no compensation. Not the browser's own scroll
342
+ // anchoring, which not every engine provides.
343
+ _prependAnchored(listElement, items) {
344
+ this._setAnchor();
345
+ listElement.insertAdjacentHTML('afterbegin', items);
346
+ this._restoreAnchor();
311
347
  }
312
- _requestFrame() {
313
- if (this._requested || this._rendered.done) {
314
- return;
315
- }
316
- this._requested = true;
317
- (0, _commons.requestAnimationFrame)(timestamp => this._frame(timestamp));
348
+ _setAnchor() {
349
+ const scrollable = this._getScrollable();
350
+ const element = this._getItemElements(this._element)[0];
351
+ this._anchor = scrollable && element ? {
352
+ element,
353
+ scrollable,
354
+ top: this._relativeTop(element, scrollable)
355
+ } : undefined;
318
356
  }
319
- _frame(timestamp) {
320
- this._requested = false;
321
- if (!this._element.isConnected) {
322
- // the element is gone (re-render, thread switch); stop typing
323
- this._onDone && this._onDone();
357
+ _restoreAnchor() {
358
+ const anchor = this._anchor;
359
+ if (!anchor) {
324
360
  return;
325
361
  }
326
- const prev = this._rendered;
327
- const prevTimestamp = this._timestamp !== undefined ? this._timestamp : timestamp;
328
- if (this._dataDone && this._doneAt === undefined) {
329
- this._doneAt = timestamp;
362
+ const {
363
+ element,
364
+ scrollable,
365
+ top
366
+ } = anchor;
367
+ if (!element.isConnected) {
368
+ this._anchor = undefined;
369
+ return;
330
370
  }
331
- const cursor = this._getNextCursor(prev.cursor, this._doneAt, prevTimestamp, timestamp);
332
- this._rendered = this._renderer.update(this._element, prev, {
333
- value: this._value,
334
- cursor,
335
- timestamp,
336
- done: this._dataDone
337
- });
338
- this._timestamp = timestamp;
339
- this._onUpdate && this._onUpdate();
340
- if (!this._rendered.done) {
341
- this._requestFrame();
342
- } else {
343
- this._onDone && this._onDone();
371
+ const delta = this._relativeTop(element, scrollable) - top;
372
+ if (delta) {
373
+ scrollable.scrollTop += delta;
344
374
  }
345
375
  }
346
- }
376
+ _relativeTop(element, scrollable) {
377
+ return element.getBoundingClientRect().top - scrollable.getBoundingClientRect().top;
378
+ }
379
+ _dropAnchor() {
380
+ this._anchor = undefined;
381
+ }
382
+ }
383
+ exports.default = MessagesLayout;