@hayasaka7/haya-pet 0.2.7 → 0.2.8

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/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ All notable changes to HAYA Pet are documented here. This project adheres to
7
7
  > 0.2.0 npm publish; they are listed under 0.2.1, which is the first version that
8
8
  > ships them.
9
9
 
10
+ ## [0.2.8]
11
+
12
+ ### Fixed
13
+ - **Scrolling the session panel no longer fights you.** With four or more
14
+ sessions the bubble panel scrolls (introduced in 0.2.7), but two bugs made it
15
+ unusable: the scroll position snapped back to the top on every status update,
16
+ and a wheel scroll or scrollbar drag would "disconnect" mid-gesture and need
17
+ restarting. Both came from the panel rebuilding its entire DOM on each refresh
18
+ (every session push plus a 2 s linger tick). The panel now renders
19
+ incrementally — the list and each session's bubble persist across updates and
20
+ are mutated in place — so the scroll position holds and a gesture stays
21
+ attached to its element. The status spinner also no longer restarts on every
22
+ refresh.
23
+
10
24
  ## [0.2.7]
11
25
 
12
26
  ### Fixed
@@ -5,6 +5,13 @@
5
5
  // Consumes the bubble view models produced by packages/session-core/bubble-view.js
6
6
  // (already shaped + sorted by the main process), so this module is pure DOM glue.
7
7
  // Status kinds come from `bubble.statusKind`: working | done | attention | failed.
8
+ //
9
+ // Rendering is INCREMENTAL, not a full rebuild: the list element and each
10
+ // session's bubble persist across updates and are mutated in place. Sessions
11
+ // push status constantly (plus a linger tick), and a wheel scroll or scrollbar
12
+ // drag is bound to the live element — replacing the node mid-gesture made the
13
+ // browser drop the gesture ("scroll disconnects, redo it"). Keeping the nodes
14
+ // stable also avoids restarting the spinner animation on every refresh.
8
15
 
9
16
  const STATUS_GLYPH = Object.freeze({
10
17
  working: "", // animated spinner drawn via CSS
@@ -17,6 +24,11 @@ const STATUS_GLYPH = Object.freeze({
17
24
  export function createBubbleList(container, { collapsed = false, onRender } = {}) {
18
25
  let lastBubbles = [];
19
26
  let isCollapsed = collapsed;
27
+ let lastScrollTop = 0;
28
+ let previousKinds = {};
29
+ // Persistent nodes, reused across paints (see the module header).
30
+ let folderButtonEl;
31
+ let listEl;
20
32
 
21
33
  function toggle() {
22
34
  isCollapsed = !isCollapsed;
@@ -24,28 +36,64 @@ export function createBubbleList(container, { collapsed = false, onRender } = {}
24
36
  }
25
37
 
26
38
  function paint() {
27
- container.innerHTML = "";
39
+ // Capture the live scroll position before anything can tear the list down.
40
+ if (listEl) {
41
+ lastScrollTop = listEl.scrollTop;
42
+ }
43
+ const scrollTargetSessionId = resolveScrollTarget(lastBubbles, previousKinds);
44
+ previousKinds = Object.fromEntries(lastBubbles.map((bubble) => [bubble.sessionId, bubble.statusKind]));
28
45
 
29
46
  // Nothing running -> keep the overlay clean (just the pet, no folder button).
30
- if (lastBubbles.length > 0) {
31
- container.appendChild(renderFolderButton(lastBubbles, isCollapsed, toggle));
47
+ if (lastBubbles.length === 0) {
48
+ clearContainer();
49
+ onRender?.();
50
+ return;
51
+ }
32
52
 
33
- if (!isCollapsed) {
34
- const list = document.createElement("div");
53
+ if (!folderButtonEl) {
54
+ folderButtonEl = createFolderButton(toggle);
55
+ container.appendChild(folderButtonEl);
56
+ }
57
+ updateFolderButton(folderButtonEl, lastBubbles, isCollapsed);
58
+
59
+ if (isCollapsed) {
60
+ // Drop the list while collapsed; it's rebuilt (with restored scroll) on
61
+ // the next expand. Collapse/expand is an explicit user action, so losing
62
+ // an in-progress scroll gesture there is fine.
63
+ if (listEl) {
64
+ listEl.remove();
65
+ listEl = undefined;
66
+ }
67
+ } else {
68
+ if (!listEl) {
35
69
  // The list itself must be pointer-active (not just the bubbles): with
36
70
  // more than three sessions it scrolls, and the scrollbar + the gaps
37
71
  // between bubbles belong to the list element — if it stayed
38
72
  // click-through, wheel/drag there would fall through to the desktop.
39
- list.className = "bubble-list interactive";
40
- for (const bubble of lastBubbles) {
41
- list.appendChild(renderBubble(bubble));
42
- }
43
- container.appendChild(list);
73
+ listEl = document.createElement("div");
74
+ listEl.className = "bubble-list interactive";
75
+ container.appendChild(listEl);
44
76
  }
77
+ reconcileBubbles(listEl, lastBubbles);
45
78
  }
46
79
 
47
- // Let the host reposition the panel now that its size is known.
80
+ // Let the host reposition the panel now that its size is known. This must
81
+ // happen BEFORE the scroll restore below: the host's placement pass is what
82
+ // applies the list's max-height cap (.bubble-list has none in CSS), and
83
+ // until that cap exists the list is as tall as its content, so any
84
+ // scrollTop assigned to it would clamp back to 0.
48
85
  onRender?.();
86
+
87
+ if (listEl) {
88
+ applyListScroll(listEl, scrollTargetSessionId, lastScrollTop);
89
+ lastScrollTop = listEl.scrollTop;
90
+ }
91
+ }
92
+
93
+ function clearContainer() {
94
+ container.innerHTML = "";
95
+ folderButtonEl = undefined;
96
+ listEl = undefined;
49
97
  }
50
98
 
51
99
  return {
@@ -63,14 +111,73 @@ export function createBubbleList(container, { collapsed = false, onRender } = {}
63
111
  };
64
112
  }
65
113
 
66
- function renderFolderButton(bubbles, collapsed, onToggle) {
114
+ // Reuses bubble elements for sessions that persist (mutating them in place),
115
+ // creates elements for new sessions, removes elements for gone sessions, and
116
+ // orders the list to match the payload — all without replacing the list node.
117
+ function reconcileBubbles(list, bubbles) {
118
+ const existing = new Map();
119
+ for (const child of Array.from(list.children)) {
120
+ if (child.dataset?.sessionId) {
121
+ existing.set(child.dataset.sessionId, child);
122
+ }
123
+ }
124
+
125
+ const desired = new Set(bubbles.map((bubble) => bubble.sessionId));
126
+ for (const [sessionId, el] of existing) {
127
+ if (!desired.has(sessionId)) {
128
+ el.remove();
129
+ existing.delete(sessionId);
130
+ }
131
+ }
132
+
133
+ bubbles.forEach((bubble, index) => {
134
+ let el = existing.get(bubble.sessionId);
135
+ if (el) {
136
+ applyBubble(el, bubble);
137
+ } else {
138
+ el = renderBubble(bubble);
139
+ }
140
+ // Place el at `index`. insertBefore moves an already-present node, so a
141
+ // forward walk converges the order in one pass (sessionIds are unique, so
142
+ // el is never already in the finalized prefix [0..index-1]).
143
+ if (list.children[index] !== el) {
144
+ list.insertBefore(el, list.children[index] ?? null);
145
+ }
146
+ });
147
+ }
148
+
149
+ const URGENT_KINDS = new Set(["attention", "failed"]);
150
+ function resolveScrollTarget(bubbles, previousKinds) {
151
+ let attentionSessionId;
152
+ for (const bubble of bubbles) {
153
+ if (!URGENT_KINDS.has(bubble.statusKind) || previousKinds[bubble.sessionId] === bubble.statusKind) {
154
+ continue;
155
+ }
156
+ if (bubble.statusKind === "failed") {
157
+ return bubble.sessionId;
158
+ }
159
+ attentionSessionId ??= bubble.sessionId;
160
+ }
161
+ return attentionSessionId;
162
+ }
163
+
164
+ function applyListScroll(list, sessionId, fallbackScrollTop) {
165
+ if (sessionId) {
166
+ const target = Array.from(list.children).find((child) => child.dataset.sessionId === sessionId);
167
+ if (target) {
168
+ list.scrollTop = target.offsetTop;
169
+ return;
170
+ }
171
+ }
172
+ list.scrollTop = fallbackScrollTop;
173
+ }
174
+
175
+ function createFolderButton(onToggle) {
67
176
  const btn = document.createElement("button");
68
177
  // "interactive" marks the only pointer-active regions so the rest of the
69
178
  // overlay window can stay click-through (see pet-window.js).
70
179
  btn.className = "folder-toggle interactive";
71
180
  btn.type = "button";
72
- btn.setAttribute("aria-expanded", String(!collapsed));
73
- btn.title = collapsed ? "Show sessions" : "Hide sessions";
74
181
 
75
182
  // A simple disclosure caret (rotates when open) — quieter than a folder glyph.
76
183
  const caret = document.createElement("span");
@@ -78,49 +185,66 @@ function renderFolderButton(bubbles, collapsed, onToggle) {
78
185
 
79
186
  const count = document.createElement("span");
80
187
  count.className = "folder-count";
81
- count.textContent = String(bubbles.length);
82
188
 
83
189
  // A small dot summary of the most urgent kind, so the user can tell something
84
190
  // needs attention without opening the folder.
85
191
  const summary = document.createElement("span");
86
192
  summary.className = "folder-summary";
87
- summary.dataset.kind = mostUrgentKind(bubbles);
88
193
 
89
194
  btn.append(caret, count, summary);
90
195
  btn.addEventListener("click", onToggle);
91
196
  return btn;
92
197
  }
93
198
 
199
+ function updateFolderButton(btn, bubbles, collapsed) {
200
+ btn.setAttribute("aria-expanded", String(!collapsed));
201
+ btn.title = collapsed ? "Show sessions" : "Hide sessions";
202
+ const [, count, summary] = btn.children;
203
+ count.textContent = String(bubbles.length);
204
+ summary.dataset.kind = mostUrgentKind(bubbles);
205
+ }
206
+
94
207
  function renderBubble(bubble) {
95
208
  const el = document.createElement("div");
96
209
  el.className = "bubble interactive";
97
210
  el.dataset.sessionId = bubble.sessionId;
98
- el.dataset.kind = bubble.statusKind;
99
211
 
100
212
  const icon = document.createElement("span");
101
213
  icon.className = "status-icon";
102
- icon.dataset.kind = bubble.statusKind;
103
- icon.textContent = STATUS_GLYPH[bubble.statusKind] ?? "";
104
- icon.title = bubble.statusLabel;
105
214
 
106
215
  const body = document.createElement("div");
107
216
  body.className = "body";
108
217
 
109
218
  const title = document.createElement("div");
110
219
  title.className = "title";
111
- title.innerHTML = `<span class="client">${escapeHtml(bubble.clientName)}</span> ` +
112
- `<span class="project">${escapeHtml(bubble.projectName)}</span>`;
113
220
 
114
221
  const activity = document.createElement("div");
115
222
  activity.className = "activity";
116
- activity.textContent = bubble.summary;
117
- activity.title = `${bubble.statusLabel} · ${bubble.elapsedLabel}`;
118
223
 
119
224
  body.append(title, activity);
120
225
  el.append(icon, body);
226
+
227
+ applyBubble(el, bubble);
121
228
  return el;
122
229
  }
123
230
 
231
+ // Writes a bubble's dynamic content. Used for both fresh elements (from
232
+ // renderBubble) and reused ones, so a session update mutates the live node.
233
+ function applyBubble(el, bubble) {
234
+ el.dataset.kind = bubble.statusKind;
235
+
236
+ const [icon, body] = el.children;
237
+ icon.dataset.kind = bubble.statusKind;
238
+ icon.textContent = STATUS_GLYPH[bubble.statusKind] ?? "";
239
+ icon.title = bubble.statusLabel;
240
+
241
+ const [title, activity] = body.children;
242
+ title.innerHTML = `<span class="client">${escapeHtml(bubble.clientName)}</span> ` +
243
+ `<span class="project">${escapeHtml(bubble.projectName)}</span>`;
244
+ activity.textContent = bubble.summary;
245
+ activity.title = `${bubble.statusLabel} · ${bubble.elapsedLabel}`;
246
+ }
247
+
124
248
  // Picks the kind that should win the collapsed-folder dot: a failure or a
125
249
  // request for attention always beats ongoing work, which beats done/idle.
126
250
  const KIND_RANK = Object.freeze({ failed: 0, attention: 1, working: 2, done: 3, idle: 4 });
@@ -0,0 +1,347 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import { createBubbleList } from "../src/renderer/session-bubbles.js";
4
+ import { resolveBubbleListMaxHeight } from "../src/main/bubble-list-viewport.js";
5
+
6
+ // With five 48px bubbles spaced 54px apart, the host caps the list at the
7
+ // third bubble's bottom (156px) while the content runs to 264px, so the
8
+ // largest reachable scrollTop is 264 - 156 = 108.
9
+ const MAX_SCROLL_TOP = 108;
10
+
11
+ test("preserves bubble-list scroll position across ordinary session updates", () => {
12
+ const restoreDocument = installFakeDocument();
13
+ try {
14
+ const container = new FakeElement("div");
15
+ const bubbles = makeBubbles(["s1", "s2", "s3", "s4", "s5"]);
16
+ const listView = createBubbleList(container, { onRender: createHostOnRender(container) });
17
+
18
+ listView.render(bubbles);
19
+ findList(container).scrollTop = 100;
20
+
21
+ listView.render(bubbles.map((bubble) =>
22
+ bubble.sessionId === "s4" ? { ...bubble, statusKind: "done", statusLabel: "Done" } : bubble
23
+ ));
24
+
25
+ assert.equal(findList(container).scrollTop, 100);
26
+ } finally {
27
+ restoreDocument();
28
+ }
29
+ });
30
+
31
+ test("keeps the same list element across session updates so scroll gestures stay attached", () => {
32
+ const restoreDocument = installFakeDocument();
33
+ try {
34
+ const container = new FakeElement("div");
35
+ const bubbles = makeBubbles(["s1", "s2", "s3", "s4", "s5"]);
36
+ const listView = createBubbleList(container, { onRender: createHostOnRender(container) });
37
+
38
+ listView.render(bubbles);
39
+ const firstList = findList(container);
40
+
41
+ listView.render(bubbles.map((bubble) =>
42
+ bubble.sessionId === "s2" ? { ...bubble, statusKind: "done", statusLabel: "Done" } : bubble
43
+ ));
44
+
45
+ // A wheel scroll or a scrollbar drag is bound to the element itself;
46
+ // replacing the node mid-gesture makes the browser drop the gesture.
47
+ assert.equal(findList(container), firstList);
48
+ } finally {
49
+ restoreDocument();
50
+ }
51
+ });
52
+
53
+ test("keeps bubble elements for sessions that persist, updating them in place", () => {
54
+ const restoreDocument = installFakeDocument();
55
+ try {
56
+ const container = new FakeElement("div");
57
+ const bubbles = makeBubbles(["s1", "s2", "s3", "s4", "s5"]);
58
+ const listView = createBubbleList(container, { onRender: createHostOnRender(container) });
59
+
60
+ listView.render(bubbles);
61
+ const firstBubble = findBubble(container, "s2");
62
+
63
+ listView.render(bubbles.map((bubble) =>
64
+ bubble.sessionId === "s2" ? { ...bubble, statusKind: "done", statusLabel: "Done", summary: "finished" } : bubble
65
+ ));
66
+
67
+ const updated = findBubble(container, "s2");
68
+ assert.equal(updated, firstBubble);
69
+ assert.equal(updated.dataset.kind, "done");
70
+ assert.equal(findActivity(updated).textContent, "finished");
71
+ } finally {
72
+ restoreDocument();
73
+ }
74
+ });
75
+
76
+ test("drops ended sessions and inserts new ones in payload order", () => {
77
+ const restoreDocument = installFakeDocument();
78
+ try {
79
+ const container = new FakeElement("div");
80
+ const listView = createBubbleList(container, { onRender: createHostOnRender(container) });
81
+
82
+ listView.render(makeBubbles(["s1", "s2", "s3", "s4", "s5"]));
83
+ listView.render(makeBubbles(["s2", "s6", "s4"]));
84
+
85
+ const ids = findList(container).children.map((child) => child.dataset.sessionId);
86
+ assert.deepEqual(ids, ["s2", "s6", "s4"]);
87
+ } finally {
88
+ restoreDocument();
89
+ }
90
+ });
91
+
92
+ test("updates the folder button in place (count and urgency dot)", () => {
93
+ const restoreDocument = installFakeDocument();
94
+ try {
95
+ const container = new FakeElement("div");
96
+ const listView = createBubbleList(container, { onRender: createHostOnRender(container) });
97
+
98
+ listView.render(makeBubbles(["s1", "s2", "s3", "s4", "s5"]));
99
+ const button = findButton(container);
100
+
101
+ listView.render(makeBubbles(["s1", "s2", "s3"]).map((bubble) =>
102
+ bubble.sessionId === "s3" ? { ...bubble, statusKind: "failed", statusLabel: "Failed" } : bubble
103
+ ));
104
+
105
+ assert.equal(findButton(container), button);
106
+ assert.equal(childByClass(button, "folder-count").textContent, "3");
107
+ assert.equal(childByClass(button, "folder-summary").dataset.kind, "failed");
108
+ } finally {
109
+ restoreDocument();
110
+ }
111
+ });
112
+
113
+ test("preserves scroll position across collapse and expand", () => {
114
+ const restoreDocument = installFakeDocument();
115
+ try {
116
+ const container = new FakeElement("div");
117
+ const bubbles = makeBubbles(["s1", "s2", "s3", "s4", "s5"]);
118
+ const listView = createBubbleList(container, { onRender: createHostOnRender(container) });
119
+
120
+ listView.render(bubbles);
121
+ findList(container).scrollTop = 100;
122
+
123
+ listView.toggle();
124
+ assert.equal(findList(container), undefined);
125
+ listView.toggle();
126
+
127
+ assert.equal(findList(container).scrollTop, 100);
128
+ } finally {
129
+ restoreDocument();
130
+ }
131
+ });
132
+
133
+ test("scrolls to a session when it newly needs attention", () => {
134
+ const restoreDocument = installFakeDocument();
135
+ try {
136
+ const container = new FakeElement("div");
137
+ const bubbles = makeBubbles(["s1", "s2", "s3", "s4", "s5"]);
138
+ const listView = createBubbleList(container, { onRender: createHostOnRender(container) });
139
+
140
+ listView.render(bubbles);
141
+ findList(container).scrollTop = 0;
142
+
143
+ listView.render(bubbles.map((bubble) =>
144
+ bubble.sessionId === "s5" ? { ...bubble, statusKind: "attention", statusLabel: "Needs attention" } : bubble
145
+ ));
146
+
147
+ assert.equal(findList(container).scrollTop, MAX_SCROLL_TOP);
148
+ } finally {
149
+ restoreDocument();
150
+ }
151
+ });
152
+
153
+ test("scrolls to a session when it newly fails", () => {
154
+ const restoreDocument = installFakeDocument();
155
+ try {
156
+ const container = new FakeElement("div");
157
+ const bubbles = makeBubbles(["s1", "s2", "s3", "s4", "s5"]);
158
+ const listView = createBubbleList(container, { onRender: createHostOnRender(container) });
159
+
160
+ listView.render(bubbles);
161
+ findList(container).scrollTop = 0;
162
+
163
+ listView.render(bubbles.map((bubble) =>
164
+ bubble.sessionId === "s5" ? { ...bubble, statusKind: "failed", statusLabel: "Failed" } : bubble
165
+ ));
166
+
167
+ assert.equal(findList(container).scrollTop, MAX_SCROLL_TOP);
168
+ } finally {
169
+ restoreDocument();
170
+ }
171
+ });
172
+
173
+ test("clears everything when no sessions remain", () => {
174
+ const restoreDocument = installFakeDocument();
175
+ try {
176
+ const container = new FakeElement("div");
177
+ const listView = createBubbleList(container, { onRender: createHostOnRender(container) });
178
+
179
+ listView.render(makeBubbles(["s1", "s2"]));
180
+ listView.render([]);
181
+
182
+ assert.equal(container.children.length, 0);
183
+ } finally {
184
+ restoreDocument();
185
+ }
186
+ });
187
+
188
+ function makeBubbles(sessionIds) {
189
+ return sessionIds.map((sessionId) => ({
190
+ sessionId,
191
+ statusKind: "working",
192
+ statusLabel: "Working",
193
+ clientName: "Codex",
194
+ projectName: sessionId,
195
+ summary: "running",
196
+ elapsedLabel: "1s"
197
+ }));
198
+ }
199
+
200
+ function findList(container) {
201
+ return container.children.find((child) => child.className === "bubble-list interactive");
202
+ }
203
+
204
+ function findButton(container) {
205
+ return container.children.find((child) => child.className === "folder-toggle interactive");
206
+ }
207
+
208
+ function findBubble(container, sessionId) {
209
+ return findList(container).children.find((child) => child.dataset.sessionId === sessionId);
210
+ }
211
+
212
+ function findActivity(bubble) {
213
+ const body = childByClass(bubble, "body");
214
+ return childByClass(body, "activity");
215
+ }
216
+
217
+ function childByClass(parent, className) {
218
+ return parent.children.find((child) => child.className === className);
219
+ }
220
+
221
+ // Mirrors pet-window's placePanel(): after each render the host measures the
222
+ // bubbles and applies the max-height cap. The list only becomes scrollable
223
+ // once this runs — the scroll-restore regression lived in that gap.
224
+ function createHostOnRender(container, room = 1000) {
225
+ return () => {
226
+ const list = findList(container);
227
+ if (!list) {
228
+ return;
229
+ }
230
+ const bubbleBottoms = list.children.map((child) => child.offsetTop + child.offsetHeight);
231
+ list.style.maxHeight = `${resolveBubbleListMaxHeight({ room, bubbleBottoms })}px`;
232
+ };
233
+ }
234
+
235
+ function installFakeDocument() {
236
+ const previousDocument = globalThis.document;
237
+ globalThis.document = {
238
+ createElement: (tagName) => new FakeElement(tagName)
239
+ };
240
+ return () => {
241
+ globalThis.document = previousDocument;
242
+ };
243
+ }
244
+
245
+ class FakeElement {
246
+ constructor(tagName) {
247
+ this.tagName = tagName.toUpperCase();
248
+ this.children = [];
249
+ this.dataset = {};
250
+ this.attributes = {};
251
+ this.style = {};
252
+ this.parentElement = undefined;
253
+ this.className = "";
254
+ this.textContent = "";
255
+ this.title = "";
256
+ this.type = "";
257
+ this._scrollTop = 0;
258
+ }
259
+
260
+ set innerHTML(_value) {
261
+ for (const child of this.children) {
262
+ child.parentElement = undefined;
263
+ }
264
+ this.children = [];
265
+ }
266
+
267
+ get innerHTML() {
268
+ return "";
269
+ }
270
+
271
+ appendChild(child) {
272
+ child.parentElement?.removeChild(child);
273
+ child.parentElement = this;
274
+ this.children.push(child);
275
+ return child;
276
+ }
277
+
278
+ append(...children) {
279
+ for (const child of children) {
280
+ this.appendChild(child);
281
+ }
282
+ }
283
+
284
+ insertBefore(child, reference) {
285
+ child.parentElement?.removeChild(child);
286
+ child.parentElement = this;
287
+ const index = reference ? this.children.indexOf(reference) : -1;
288
+ if (index === -1) {
289
+ this.children.push(child);
290
+ } else {
291
+ this.children.splice(index, 0, child);
292
+ }
293
+ return child;
294
+ }
295
+
296
+ removeChild(child) {
297
+ const index = this.children.indexOf(child);
298
+ if (index !== -1) {
299
+ this.children.splice(index, 1);
300
+ child.parentElement = undefined;
301
+ }
302
+ return child;
303
+ }
304
+
305
+ remove() {
306
+ this.parentElement?.removeChild(this);
307
+ }
308
+
309
+ setAttribute(name, value) {
310
+ this.attributes[name] = String(value);
311
+ }
312
+
313
+ addEventListener(_name, _handler) {}
314
+
315
+ get offsetTop() {
316
+ const index = this.parentElement ? this.parentElement.children.indexOf(this) : 0;
317
+ return Math.max(index, 0) * 54;
318
+ }
319
+
320
+ get offsetHeight() {
321
+ return this.className === "bubble interactive" ? 48 : 24;
322
+ }
323
+
324
+ // The content height: bottom edge of the last child, like the browser.
325
+ get scrollHeight() {
326
+ const last = this.children[this.children.length - 1];
327
+ return last ? last.offsetTop + last.offsetHeight : 0;
328
+ }
329
+
330
+ // Without a max-height cap the element grows to fit its content, which
331
+ // makes it unscrollable (clientHeight === scrollHeight).
332
+ get clientHeight() {
333
+ const cap = Number.parseFloat(this.style.maxHeight);
334
+ return Number.isFinite(cap) ? Math.min(cap, this.scrollHeight) : this.scrollHeight;
335
+ }
336
+
337
+ get scrollTop() {
338
+ return this._scrollTop;
339
+ }
340
+
341
+ // Browsers clamp scrollTop into [0, scrollHeight - clientHeight]; the real
342
+ // bug hid behind a fake that accepted any value.
343
+ set scrollTop(value) {
344
+ const maxScroll = Math.max(0, this.scrollHeight - this.clientHeight);
345
+ this._scrollTop = Math.min(Math.max(value, 0), maxScroll);
346
+ }
347
+ }
@@ -103,6 +103,14 @@ with mouse-move forwarding). The pet is positioned inside the window and dragged
103
103
  via CSS; the bubble panel is placed on whichever side of the pet has room so it
104
104
  stays fully on-screen. The pet currently lives on a single display's work area.
105
105
 
106
+ The bubble panel shows at most three sessions and scrolls for the rest (capped
107
+ by the smaller of a height budget and a count budget, see
108
+ `bubble-list-viewport.js`). It renders **incrementally** — the list element and
109
+ each session's bubble persist across updates and are mutated in place rather
110
+ than rebuilt — because status pushes and a 2 s linger tick arrive constantly,
111
+ and replacing the node under the cursor would drop an in-progress scroll
112
+ gesture and reset the scroll position.
113
+
106
114
  ## Distribution & runtime dependencies
107
115
 
108
116
  - `electron` is a **runtime dependency** (not just a dev tool), because
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hayasaka7/haya-pet",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "type": "module",
5
5
  "description": "Generic AI CLI pet runtime foundation.",
6
6
  "keywords": [