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

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 (44) hide show
  1. package/dist/cjs/asset/svgs.js +4 -2
  2. package/dist/cjs/constants.js +2 -0
  3. package/dist/cjs/defaults.js +100 -10
  4. package/dist/cjs/element/container/index.js +14 -0
  5. package/dist/cjs/element/container/miso-message-item.js +91 -0
  6. package/dist/cjs/element/container/miso-thread-item.js +91 -0
  7. package/dist/cjs/element/role/index.js +7 -0
  8. package/dist/cjs/element/role/miso-delete.js +20 -0
  9. package/dist/cjs/layout/button/button.js +45 -5
  10. package/dist/cjs/layout/button/checkbox.js +2 -1
  11. package/dist/cjs/layout/container/item-container.js +99 -0
  12. package/dist/cjs/layout/index.js +14 -0
  13. package/dist/cjs/layout/input/search-box.js +9 -4
  14. package/dist/cjs/layout/list/collection.js +10 -3
  15. package/dist/cjs/layout/list/messages.js +239 -226
  16. package/dist/cjs/layout/list/threads.js +97 -155
  17. package/dist/cjs/layout/templates.js +65 -38
  18. package/dist/cjs/layout/text/question.js +71 -0
  19. package/dist/cjs/layout/text/typewriter/index.js +14 -21
  20. package/dist/cjs/util/dom.js +21 -0
  21. package/dist/cjs/version.js +1 -1
  22. package/package.json +3 -3
  23. package/src/CLAUDE.md +6 -2
  24. package/src/asset/svgs.js +3 -1
  25. package/src/constants.js +2 -0
  26. package/src/defaults.js +71 -4
  27. package/src/element/container/index.js +2 -0
  28. package/src/element/container/miso-message-item.js +94 -0
  29. package/src/element/container/miso-thread-item.js +94 -0
  30. package/src/element/role/index.js +1 -0
  31. package/src/element/role/miso-delete.js +16 -0
  32. package/src/layout/button/button.js +40 -4
  33. package/src/layout/button/checkbox.js +2 -1
  34. package/src/layout/container/item-container.js +99 -0
  35. package/src/layout/index.js +2 -0
  36. package/src/layout/input/search-box.js +4 -2
  37. package/src/layout/list/collection.js +12 -4
  38. package/src/layout/list/messages.js +202 -204
  39. package/src/layout/list/threads.js +90 -130
  40. package/src/layout/templates.js +74 -39
  41. package/src/layout/text/question.js +58 -0
  42. package/src/layout/text/typewriter/index.js +10 -15
  43. package/src/util/dom.js +16 -0
  44. package/src/version.js +1 -1
@@ -81,6 +81,12 @@ Object.defineProperty(exports, "HorizontalLayout", {
81
81
  return _horizontal.default;
82
82
  }
83
83
  });
84
+ Object.defineProperty(exports, "ItemContainerLayout", {
85
+ enumerable: true,
86
+ get: function () {
87
+ return _itemContainer.default;
88
+ }
89
+ });
84
90
  Object.defineProperty(exports, "ListLayout", {
85
91
  enumerable: true,
86
92
  get: function () {
@@ -105,6 +111,12 @@ Object.defineProperty(exports, "OptionListLayout", {
105
111
  return _optionList.default;
106
112
  }
107
113
  });
114
+ Object.defineProperty(exports, "QuestionLayout", {
115
+ enumerable: true,
116
+ get: function () {
117
+ return _question.default;
118
+ }
119
+ });
108
120
  Object.defineProperty(exports, "SearchBoxLayout", {
109
121
  enumerable: true,
110
122
  get: function () {
@@ -143,6 +155,7 @@ Object.defineProperty(exports, "TypewriterLayout", {
143
155
  });
144
156
  var _template = _interopRequireDefault(require("./template.js"));
145
157
  var _container = _interopRequireDefault(require("./container/container.js"));
158
+ var _itemContainer = _interopRequireDefault(require("./container/item-container.js"));
146
159
  var _error = _interopRequireDefault(require("./error/error.js"));
147
160
  var _banner = _interopRequireDefault(require("./banner/banner.js"));
148
161
  var _collection = _interopRequireDefault(require("./list/collection.js"));
@@ -157,6 +170,7 @@ var _searchBox = _interopRequireDefault(require("./input/search-box.js"));
157
170
  var _optionList = _interopRequireDefault(require("./input/option-list.js"));
158
171
  var _feedback = _interopRequireDefault(require("./radio/feedback.js"));
159
172
  var _text = _interopRequireDefault(require("./text/text.js"));
173
+ var _question = _interopRequireDefault(require("./text/question.js"));
160
174
  var _index = _interopRequireDefault(require("./text/typewriter/index.js"));
161
175
  var _affiliation = _interopRequireDefault(require("./affiliation/affiliation.js"));
162
176
  var _facets = _interopRequireDefault(require("./input/facets.js"));
@@ -406,10 +406,15 @@ class SearchBoxLayout extends _template.default {
406
406
  return;
407
407
  }
408
408
  this.close();
409
- // TODO: q -> value
410
- this._view.hub.update(_clientSdkWorkflow.fields.query(), {
411
- q: value
412
- });
409
+ // a view event, like the buttons: the workflow routes it into its query
410
+ // flow (or its own submission semantics), the layout stays hands-off
411
+ const {
412
+ session
413
+ } = this._view._state || {};
414
+ this._view._emit('submit', Object.freeze({
415
+ session,
416
+ value
417
+ }));
413
418
  if (this.options.clearOnSubmit) {
414
419
  const inputElement = this._element && this._element.querySelector(`[data-role="input"]`);
415
420
  if (inputElement) {
@@ -96,10 +96,10 @@ const DEFAULT_TEMPLATES = Object.freeze({
96
96
  image: _templates.image,
97
97
  question: _templates.question,
98
98
  thread: _templates.thread,
99
+ threadBody: _templates.threadBody,
99
100
  threadMenuBlock: _templates.threadMenuBlock,
100
101
  message: _templates.message,
101
- messageQuestionBlock: _templates.messageQuestionBlock,
102
- messageAnswerBlock: _templates.messageAnswerBlock,
102
+ messageBody: _templates.messageBody,
103
103
  root,
104
104
  [_constants.STATUS.READY]: ready,
105
105
  empty: () => ``,
@@ -208,7 +208,14 @@ class CollectionLayout extends _template.default {
208
208
  return element.querySelector('[data-role="list"]');
209
209
  }
210
210
  _getItemElements(element) {
211
- return element ? Array.from(element.querySelectorAll(`[data-role="item"]`)) : [];
211
+ if (!element) {
212
+ return [];
213
+ }
214
+ // an item may host a nested collection with items of its own (a
215
+ // conversation message and its source cards): ours are only those whose
216
+ // closest list is this layout's own list
217
+ const listElement = this._getListElement(element);
218
+ return Array.from((listElement || element).querySelectorAll(`[data-role="item"]`)).filter(item => item.closest(`[data-role="list"]`) === listElement);
212
219
  }
213
220
  _onClick(event) {
214
221
  const element = event.target.closest(`[data-role="item"]`);
@@ -4,34 +4,59 @@ 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
8
  var _collection = _interopRequireDefault(require("./collection.js"));
11
- var _utils = require("../text/typewriter/utils.js");
12
- var _templates = require("../templates.js");
13
- var _dom = require("../../util/dom.js");
14
- const _excluded = ["className"];
9
+ const _excluded = ["className", "templates"];
15
10
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
16
11
  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
12
  const TYPE = _constants.LAYOUT_TYPE.MESSAGES;
18
13
  const DEFAULT_CLASSNAME = 'miso-messages';
19
- const TYPEWRITER_CLASSNAME = 'miso-typewriter';
14
+
15
+ // the infinite scroll trigger sits ABOVE the list — older messages load as
16
+ // the user scrolls up, unlike the stock collection root's bottom trigger
17
+ function root(layout, state) {
18
+ const {
19
+ className,
20
+ role,
21
+ templates,
22
+ options
23
+ } = layout;
24
+ const {
25
+ status
26
+ } = state;
27
+ const roleAttr = role ? ` data-role="${role}"` : '';
28
+ const itemTypeAttr = options.itemType ? ` data-item-type="${options.itemType}"` : '';
29
+ return `<div class="${className} ${status}"${roleAttr}${itemTypeAttr}>${templates.trigger(layout, state)}${status === _constants.STATUS.READY ? templates[status](layout, state) : ''}${templates.loading(layout, state)}</div>`;
30
+ }
20
31
 
21
32
  /**
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`):
33
+ * The conversation panel of the chat history interface: a shell that renders
34
+ * one <miso-message-item> container element per message item, incrementally —
35
+ * fresh items land at either end of the list: appended messages (a posted
36
+ * follow-up question) render as new items at the bottom, while an older
37
+ * page fetched by the more flow (infinite scroll: the trigger, placed above
38
+ * the list, fires the `more` hub field as the user scrolls up) renders as
39
+ * prepended items — told apart by which boundary the already-rendered
40
+ * records line up with.
41
+ * The content of a message is not rendered here: each <miso-message-item> is
42
+ * assigned its item subworkflow (workflow._getMessageWorkflow, off the item
43
+ * binding) in the post-render sync pass, and the role elements inside
44
+ * (question, answer, ...) render through that workflow's own layouts.
45
+ *
46
+ * All message-level presentation is the message workflow's own: a record
47
+ * without its answer body presents as status `loading`, stamped on the
48
+ * <miso-message-item> element by its container layout — there is deliberately no
49
+ * per-message loading icon; the answer typewriter's blinking caret is the
50
+ * loading indication — and the question bubble (text, authorship
51
+ * attributes) renders through the `question` layout.
27
52
  *
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
53
+ * The shell keeps the panel-level behaviors: scroll pinning (the view sticks
54
+ * to the bottom while the user has not scrolled up, following content growth
55
+ * as answers type out), scroll anchoring while scrolled up (prepended items
56
+ * — and their contents, rendering in later through their own workflows —
57
+ * must not shift the reading position), and the `ongoing` view state read
58
+ * off the data — the last message's answer absent or unfinished — which
59
+ * e.g. disables the search box while an answer is on its way.
35
60
  */
36
61
  class MessagesLayout extends _collection.default {
37
62
  static get type() {
@@ -42,172 +67,157 @@ class MessagesLayout extends _collection.default {
42
67
  }
43
68
  constructor(_ref = {}) {
44
69
  let {
45
- className = DEFAULT_CLASSNAME
70
+ className = DEFAULT_CLASSNAME,
71
+ templates
46
72
  } = _ref,
47
73
  options = _objectWithoutPropertiesLoose(_ref, _excluded);
48
74
  super(Object.assign({
49
- className
75
+ className,
76
+ templates: Object.assign({
77
+ root
78
+ }, templates)
50
79
  }, 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
80
  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();
81
+ this._anchor = undefined; // the scroll anchor while prepended content settles
82
+ this._ongoing = undefined;
62
83
  }
63
84
 
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;
85
+ // item identity: the question id, or the local placeholder id standing in
86
+ // for it while a posted question awaits its response — the placeholder id
87
+ // survives the settle, so the element and its workflow do too
88
+ _getItemKey(message) {
89
+ return message.placeholder_id || message.question_id || message;
79
90
  }
80
91
 
81
92
  // render //
82
93
  async render(element, state, controls = {}) {
83
- // capture the view state callback, so the typewriter can report the
84
- // `ongoing` state outside the render cycle
94
+ // capture the view state callback for the ongoing report
85
95
  this._notifyUpdate = controls.notifyUpdate;
86
96
  await super.render(element, state, controls);
87
97
  }
88
- _afterRender(element, state) {
89
- super._afterRender(element, state); // syncs bindings to the latest values
90
- 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
96
- this._scrollToBottom({
97
- force: true
98
- });
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
117
- });
118
- if (last) {
119
- displaying = this._isDisplaying(item, message);
120
- }
98
+
99
+ // incremental renders cover a pure append (a posted follow-up at the
100
+ // bottom) or a pure prepend (an older page above) — anything else
101
+ // re-renders in full
102
+ _shallRenderIncrementally(state, rendered) {
103
+ if (!super._shallRenderIncrementally(state, rendered)) {
104
+ return false;
121
105
  }
122
- this._setDisplaying(displaying);
106
+ const values = this._getItems(state) || [];
107
+ const renderedValues = rendered.value;
108
+ const count = values.length - renderedValues.length;
109
+ return count === 0 ||
110
+ // in-place changes only
111
+ this._getItemKey(values[0]) === this._getItemKey(renderedValues[0]) ||
112
+ // appended
113
+ this._getItemKey(values[count]) === this._getItemKey(renderedValues[0]); // prepended
123
114
  }
124
115
 
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
116
+ // the fresh items land at the tail (a posted follow-up) or at the head
117
+ // (an older page fetched by the more flow): told apart by which boundary
118
+ // the already-rendered records line up with
119
+ _html(state, rendered, incremental) {
120
+ if (!incremental) {
121
+ return this.templates.root(this, state);
133
122
  }
134
- const answerElement = item.querySelector('[data-role="answer"]');
135
- const typewriter = answerElement && this._typewriters.get(answerElement);
136
- return typewriter ? !typewriter.done : message.finished === false;
137
- }
138
- _syncQuestion(item, message) {
139
- const questionElement = item.querySelector('[data-role="question"]');
140
- if (!questionElement) {
141
- return;
123
+ const values = this._getItems(state) || [];
124
+ const renderedValues = rendered.value;
125
+ const count = values.length - renderedValues.length;
126
+ if (count === 0) {
127
+ return '';
142
128
  }
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;
129
+ if (this._getItemKey(values[0]) === this._getItemKey(renderedValues[0])) {
130
+ const fresh = values.slice(renderedValues.length);
131
+ return {
132
+ position: 'beforeend',
133
+ items: this.templates.items(this, state, fresh, {
134
+ offset: renderedValues.length
135
+ })
136
+ };
151
137
  }
152
- questionElement.textContent = question;
153
- questionElement.hidden = false;
138
+ const fresh = values.slice(0, count);
139
+ return {
140
+ position: 'afterbegin',
141
+ items: this.templates.items(this, state, fresh, {
142
+ offset: 0
143
+ })
144
+ };
154
145
  }
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
146
+ _render(element, {
147
+ state
148
+ }, {
149
+ notifyUpdate
150
+ }) {
151
+ const {
152
+ incremental,
153
+ html
154
+ } = state;
155
+ if (incremental) {
156
+ if (html) {
157
+ const listElement = this._getListElement(element);
158
+ if (html.position === 'afterbegin') {
159
+ this._prependAnchored(listElement, html.items);
160
+ } else {
161
+ listElement.insertAdjacentHTML('beforeend', html.items);
162
+ }
163
+ } else {
164
+ notifyUpdate(false);
165
+ }
166
+ } else {
167
+ element.innerHTML = html;
170
168
  }
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)
169
+ }
170
+ _afterRender(element, state) {
171
+ super._afterRender(element, state); // syncs bindings and (re)arms the trigger
172
+ if (!state.incremental) {
173
+ // a fresh render (thread load): jump to the bottom
174
+ this._dropAnchor();
175
+ this._scrollToBottom({
176
+ force: true
177
+ });
178
+ } else if (state.html && state.html.position === 'beforeend') {
179
+ // appended items (posted follow-up): jump to the bottom
180
+ this._scrollToBottom({
181
+ force: true
178
182
  });
179
- this._typewriters.set(answerElement, typewriter);
180
- typewriter.update(message);
183
+ }
184
+ this._watchGrowth(this._getListElement(element) || element);
185
+ this._syncWorkflows(element);
186
+ this._syncOngoing(state);
187
+ }
188
+ _unrender() {
189
+ super._unrender();
190
+ this._dropAnchor();
191
+ }
192
+
193
+ // assign each <miso-message-item> its item subworkflow, off the item binding
194
+ _syncWorkflows(element) {
195
+ const workflow = this._view && this._view.workflow;
196
+ if (!workflow || typeof workflow._getMessageWorkflow !== 'function') {
181
197
  return;
182
198
  }
183
- // a finished answer: transform in one shot
184
- if (this._renderedAnswers.get(answerElement) === message.answer) {
185
- return; // already rendered
199
+ for (const item of this._getItemElements(element)) {
200
+ const binding = this._bindings.get(item);
201
+ if (binding && item.isContainer) {
202
+ item.workflow = workflow._getMessageWorkflow(binding.value);
203
+ }
186
204
  }
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
205
  }
201
206
 
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) {
207
+ // whether an answer is on its way — pending, or still being generated —
208
+ // reported as the `ongoing` view state, e.g. for the search box to disable
209
+ // submission
210
+ _syncOngoing(state) {
211
+ const messages = state.value || [];
212
+ const last = messages[messages.length - 1];
213
+ // a message settled by a failed content fetch is not on its way anymore
214
+ const ongoing = !!last && !last.error && (last.answer === undefined || last.finished === false);
215
+ if (this._ongoing === ongoing) {
206
216
  return;
207
217
  }
208
- this._displaying = displaying;
218
+ this._ongoing = ongoing;
209
219
  this._notifyUpdate && this._notifyUpdate({
210
- ongoing: displaying
220
+ ongoing
211
221
  });
212
222
  }
213
223
 
@@ -250,6 +260,14 @@ class MessagesLayout extends _collection.default {
250
260
  this._unwatchScroll && this._unwatchScroll();
251
261
  const onScroll = () => {
252
262
  this._pinned = scrollable.scrollHeight - scrollable.scrollTop - scrollable.clientHeight < 40;
263
+ if (this._pinned) {
264
+ // back at the bottom: the reading position needs no anchor
265
+ this._anchor = undefined;
266
+ } else if (this._anchor && this._anchor.element.isConnected) {
267
+ // follow the user: the anchor holds the position they scrolled to,
268
+ // not the one where the prepend happened
269
+ this._anchor.top = this._relativeTop(this._anchor.element, scrollable);
270
+ }
253
271
  };
254
272
  scrollable.addEventListener('scroll', onScroll, {
255
273
  passive: true
@@ -262,85 +280,80 @@ class MessagesLayout extends _collection.default {
262
280
  };
263
281
  this._unsubscribes.push(() => this._unwatchScroll === unwatch && unwatch());
264
282
  }
265
- }
266
283
 
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;
284
+ // the message contents render (and type out) through their own workflows,
285
+ // outside this layout's render cycle: follow the content growth — pinned,
286
+ // the view sticks to the bottom; scrolled up with an anchor active (a
287
+ // prepended page still rendering its contents in), the anchor is held
288
+ // stationary instead
289
+ _watchGrowth(element) {
290
+ if (!element || this._watchedGrowth === element || typeof ResizeObserver === 'undefined') {
291
+ return;
292
+ }
293
+ this._unwatchGrowth && this._unwatchGrowth();
294
+ const observer = new ResizeObserver(() => this._onContentGrowth());
295
+ observer.observe(element);
296
+ this._watchedGrowth = element;
297
+ const unwatch = this._unwatchGrowth = () => {
298
+ observer.disconnect();
299
+ this._watchedGrowth = undefined;
300
+ this._unwatchGrowth = undefined;
301
+ };
302
+ this._unsubscribes.push(() => this._unwatchGrowth === unwatch && unwatch());
298
303
  }
299
- get done() {
300
- return this._rendered.done;
304
+ _onContentGrowth() {
305
+ if (this._pinned) {
306
+ this._scrollToBottom();
307
+ } else {
308
+ this._restoreAnchor();
309
+ }
301
310
  }
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();
311
+
312
+ // scroll anchoring for prepended content //
313
+ // The anchor is the previously-first item: everything prepended — and
314
+ // its content, rendering in later through the item workflows — sits
315
+ // above it, so holding the anchor stationary holds the reading position,
316
+ // while growth below it (a streaming answer at the bottom) moves nothing
317
+ // above and needs no compensation. Not the browser's own scroll
318
+ // anchoring, which not every engine provides.
319
+ _prependAnchored(listElement, items) {
320
+ this._setAnchor();
321
+ listElement.insertAdjacentHTML('afterbegin', items);
322
+ this._restoreAnchor();
311
323
  }
312
- _requestFrame() {
313
- if (this._requested || this._rendered.done) {
314
- return;
315
- }
316
- this._requested = true;
317
- (0, _commons.requestAnimationFrame)(timestamp => this._frame(timestamp));
324
+ _setAnchor() {
325
+ const scrollable = this._getScrollable();
326
+ const element = this._getItemElements(this._element)[0];
327
+ this._anchor = scrollable && element ? {
328
+ element,
329
+ scrollable,
330
+ top: this._relativeTop(element, scrollable)
331
+ } : undefined;
318
332
  }
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();
333
+ _restoreAnchor() {
334
+ const anchor = this._anchor;
335
+ if (!anchor) {
324
336
  return;
325
337
  }
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;
338
+ const {
339
+ element,
340
+ scrollable,
341
+ top
342
+ } = anchor;
343
+ if (!element.isConnected) {
344
+ this._anchor = undefined;
345
+ return;
330
346
  }
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();
347
+ const delta = this._relativeTop(element, scrollable) - top;
348
+ if (delta) {
349
+ scrollable.scrollTop += delta;
344
350
  }
345
351
  }
346
- }
352
+ _relativeTop(element, scrollable) {
353
+ return element.getBoundingClientRect().top - scrollable.getBoundingClientRect().top;
354
+ }
355
+ _dropAnchor() {
356
+ this._anchor = undefined;
357
+ }
358
+ }
359
+ exports.default = MessagesLayout;