@haikit/client 0.6.0 → 0.6.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haikit/client",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "The haikit browser runtime: streams tool payloads into interactive UI surfaces. Plain ESM, no build step, no dependencies.",
5
5
  "keywords": [
6
6
  "haikit",
@@ -12,8 +12,10 @@ export function h<K extends keyof HTMLElementTagNameMap>(
12
12
  ): HTMLElementTagNameMap[K];
13
13
 
14
14
  /**
15
- * The default transcript renderer. Replaces `root`'s children on every call and
16
- * preserves scroll-to-bottom. Deliberately plain — this is the part you are
17
- * expected to replace.
15
+ * The default transcript renderer. Replaces `root`'s children on every call.
16
+ * While the reader is at the bottom, it keeps the newest content in view,
17
+ * surfaces included once they have mounted and as they grow. Once the reader
18
+ * scrolls up, it keeps their place until they scroll back down. Deliberately
19
+ * plain — this is the part you are expected to replace.
18
20
  */
19
21
  export function renderTranscript(root: HTMLElement, chat: Chat): void;
package/src/transcript.js CHANGED
@@ -15,12 +15,109 @@ export const h = (tag, className, text) => {
15
15
 
16
16
  export function renderTranscript(root, chat) {
17
17
  const { state } = chat;
18
- const atBottom = root.scrollHeight - root.scrollTop - root.clientHeight < 160;
18
+ const follow = follower(root);
19
+
20
+ // reset() gives a new conversation a new blocks array, and a new
21
+ // conversation starts at the bottom, wherever the reader was in the last.
22
+ if (state.blocks !== follow.blocks) Object.assign(follow, { blocks: state.blocks, stick: true, top: 0 });
23
+ // A scroll the reader made since the last render, whose event hasn't arrived
24
+ // yet: taken now, or the pin below would overwrite it. Not while an earlier
25
+ // render in this task is still mounting: the reader can't scroll within a
26
+ // task, and the position read then is one clamped to a shorter transcript.
27
+ else if (!follow.mounting) follow.noticeScroll();
19
28
 
20
29
  root.replaceChildren();
21
30
  for (const block of state.blocks) root.append(renderBlock(block, chat));
22
31
 
23
- if (atBottom) root.scrollTop = root.scrollHeight;
32
+ // Surfaces mount in microtasks queued by renderBlock, in block order. This
33
+ // one is queued after them, so it runs once they have content and height:
34
+ // scrolling any earlier aims at a bottom that is about to move.
35
+ follow.mounting = true;
36
+ queueMicrotask(() => {
37
+ follow.mounting = false;
38
+ follow.stick ? follow.pin() : follow.hold();
39
+ });
40
+
41
+ // Surfaces can grow after they mount, as a table expands or an image loads.
42
+ // While following, the observer keeps the newest content in view as they do.
43
+ // Otherwise it returns the reader to their place, which a rebuild may have
44
+ // had to clamp while the surfaces above it were still short. The browser
45
+ // reports scrolls before resizes in a frame, so a scroll the reader has just
46
+ // made is already their place by then.
47
+ if (follow.observer) {
48
+ follow.observer.disconnect();
49
+ follow.observer.observe(root);
50
+ for (const child of root.children) follow.observer.observe(child);
51
+ }
52
+ }
53
+
54
+ /** Within this many pixels of the bottom, the reader is following new content. */
55
+ const NEAR_BOTTOM = 40;
56
+
57
+ const followers = new WeakMap();
58
+
59
+ /**
60
+ * Whether a transcript follows new content, and where its reader is when it
61
+ * doesn't, kept per element across renders.
62
+ *
63
+ * Only the reader's own scrolling changes either. Read from the geometry on
64
+ * each render instead, both go wrong. Following breaks for good the first time
65
+ * content grows after the scroll, which a surface mounting does every time: the
66
+ * bottom is then too far away to count, and nothing scrolls there again. And
67
+ * one chunk of the stream can render several times before any surface mounts,
68
+ * so a later render reads a position the browser has already clamped to the
69
+ * shorter transcript.
70
+ */
71
+ function follower(root) {
72
+ let follow = followers.get(root);
73
+ if (follow) return follow;
74
+
75
+ // where the transcript's own last scroll landed, so the scroll event that
76
+ // follows is not taken for the reader's
77
+ let landed = null;
78
+ const land = () => (landed = root.scrollTop);
79
+
80
+ follow = {
81
+ stick: true,
82
+ // where the reader last scrolled to
83
+ top: 0,
84
+ // the blocks array of the conversation shown
85
+ blocks: null,
86
+ // a render's surfaces are still to mount
87
+ mounting: false,
88
+ // the reader has scrolled, unless this is where the transcript's own
89
+ // last scroll landed
90
+ noticeScroll() {
91
+ if (landed !== null && Math.abs(root.scrollTop - landed) < 1) return;
92
+ // Content shrinking under a reader who scrolled up pulls them up to the
93
+ // new bottom. That is the layout moving them, not the reader, whose own
94
+ // scroll up always leaves the bottom: keep their place for when the
95
+ // content grows back.
96
+ const bottom = root.scrollHeight - root.clientHeight;
97
+ if (!follow.stick && root.scrollTop < follow.top && root.scrollTop >= bottom - 1) return;
98
+ landed = null;
99
+ follow.top = root.scrollTop;
100
+ follow.stick = root.scrollHeight - root.scrollTop - root.clientHeight < NEAR_BOTTOM;
101
+ },
102
+ pin() {
103
+ root.scrollTop = root.scrollHeight;
104
+ land();
105
+ },
106
+ // keep a reader who has scrolled up where they were, through a rebuild
107
+ hold() {
108
+ root.scrollTop = follow.top;
109
+ land();
110
+ },
111
+ observer:
112
+ typeof ResizeObserver === "undefined"
113
+ ? null
114
+ : new ResizeObserver(() => (follow.stick ? follow.pin() : follow.hold())),
115
+ };
116
+
117
+ root.addEventListener("scroll", () => follow.noticeScroll(), { passive: true });
118
+
119
+ followers.set(root, follow);
120
+ return follow;
24
121
  }
25
122
 
26
123
  function renderBlock(block, chat) {