@hayasaka7/haya-pet 0.2.6 → 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.
@@ -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,24 +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));
32
-
33
- if (!isCollapsed) {
34
- const list = document.createElement("div");
35
- list.className = "bubble-list";
36
- for (const bubble of lastBubbles) {
37
- list.appendChild(renderBubble(bubble));
38
- }
39
- container.appendChild(list);
47
+ if (lastBubbles.length === 0) {
48
+ clearContainer();
49
+ onRender?.();
50
+ return;
51
+ }
52
+
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) {
69
+ // The list itself must be pointer-active (not just the bubbles): with
70
+ // more than three sessions it scrolls, and the scrollbar + the gaps
71
+ // between bubbles belong to the list element — if it stayed
72
+ // click-through, wheel/drag there would fall through to the desktop.
73
+ listEl = document.createElement("div");
74
+ listEl.className = "bubble-list interactive";
75
+ container.appendChild(listEl);
40
76
  }
77
+ reconcileBubbles(listEl, lastBubbles);
41
78
  }
42
79
 
43
- // 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.
44
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;
45
97
  }
46
98
 
47
99
  return {
@@ -59,14 +111,73 @@ export function createBubbleList(container, { collapsed = false, onRender } = {}
59
111
  };
60
112
  }
61
113
 
62
- 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) {
63
176
  const btn = document.createElement("button");
64
177
  // "interactive" marks the only pointer-active regions so the rest of the
65
178
  // overlay window can stay click-through (see pet-window.js).
66
179
  btn.className = "folder-toggle interactive";
67
180
  btn.type = "button";
68
- btn.setAttribute("aria-expanded", String(!collapsed));
69
- btn.title = collapsed ? "Show sessions" : "Hide sessions";
70
181
 
71
182
  // A simple disclosure caret (rotates when open) — quieter than a folder glyph.
72
183
  const caret = document.createElement("span");
@@ -74,49 +185,66 @@ function renderFolderButton(bubbles, collapsed, onToggle) {
74
185
 
75
186
  const count = document.createElement("span");
76
187
  count.className = "folder-count";
77
- count.textContent = String(bubbles.length);
78
188
 
79
189
  // A small dot summary of the most urgent kind, so the user can tell something
80
190
  // needs attention without opening the folder.
81
191
  const summary = document.createElement("span");
82
192
  summary.className = "folder-summary";
83
- summary.dataset.kind = mostUrgentKind(bubbles);
84
193
 
85
194
  btn.append(caret, count, summary);
86
195
  btn.addEventListener("click", onToggle);
87
196
  return btn;
88
197
  }
89
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
+
90
207
  function renderBubble(bubble) {
91
208
  const el = document.createElement("div");
92
209
  el.className = "bubble interactive";
93
210
  el.dataset.sessionId = bubble.sessionId;
94
- el.dataset.kind = bubble.statusKind;
95
211
 
96
212
  const icon = document.createElement("span");
97
213
  icon.className = "status-icon";
98
- icon.dataset.kind = bubble.statusKind;
99
- icon.textContent = STATUS_GLYPH[bubble.statusKind] ?? "";
100
- icon.title = bubble.statusLabel;
101
214
 
102
215
  const body = document.createElement("div");
103
216
  body.className = "body";
104
217
 
105
218
  const title = document.createElement("div");
106
219
  title.className = "title";
107
- title.innerHTML = `<span class="client">${escapeHtml(bubble.clientName)}</span> ` +
108
- `<span class="project">${escapeHtml(bubble.projectName)}</span>`;
109
220
 
110
221
  const activity = document.createElement("div");
111
222
  activity.className = "activity";
112
- activity.textContent = bubble.summary;
113
- activity.title = `${bubble.statusLabel} · ${bubble.elapsedLabel}`;
114
223
 
115
224
  body.append(title, activity);
116
225
  el.append(icon, body);
226
+
227
+ applyBubble(el, bubble);
117
228
  return el;
118
229
  }
119
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
+
120
248
  // Picks the kind that should win the collapsed-folder dot: a failure or a
121
249
  // request for attention always beats ongoing work, which beats done/idle.
122
250
  const KIND_RANK = Object.freeze({ failed: 0, attention: 1, working: 2, done: 3, idle: 4 });
@@ -149,6 +149,25 @@ body {
149
149
  pointer-events: auto;
150
150
  }
151
151
 
152
+ /* Scrolling is by design (at most three bubbles are visible at once), so give
153
+ the list a slim thumb that fits the dark pills instead of the stock bar. */
154
+ .bubble-list::-webkit-scrollbar {
155
+ width: 6px;
156
+ }
157
+
158
+ .bubble-list::-webkit-scrollbar-track {
159
+ background: transparent;
160
+ }
161
+
162
+ .bubble-list::-webkit-scrollbar-thumb {
163
+ background: rgba(255, 255, 255, 0.25);
164
+ border-radius: 3px;
165
+ }
166
+
167
+ .bubble-list::-webkit-scrollbar-thumb:hover {
168
+ background: rgba(255, 255, 255, 0.4);
169
+ }
170
+
152
171
  .bubble-list[data-open-direction="down"] { top: calc(100% + 6px); }
153
172
  .bubble-list[data-open-direction="up"] { bottom: calc(100% + 6px); }
154
173
  .bubble-list[data-open-align="left"] { left: 0; right: auto; }
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import { resolveBubbleListMaxHeight } from "../src/main/bubble-list-viewport.js";
4
+
5
+ // Layout bottoms for bubbles ~48px tall with a 6px gap.
6
+ const FOUR_BUBBLES = [48, 102, 156, 210];
7
+
8
+ test("three or fewer bubbles use the height budget alone", () => {
9
+ assert.equal(resolveBubbleListMaxHeight({ room: 400, bubbleBottoms: [48, 102, 156] }), 400);
10
+ assert.equal(resolveBubbleListMaxHeight({ room: 400, bubbleBottoms: [48] }), 400);
11
+ assert.equal(resolveBubbleListMaxHeight({ room: 400, bubbleBottoms: [] }), 400);
12
+ });
13
+
14
+ test("more than three bubbles cap the viewport at the third bubble's bottom", () => {
15
+ assert.equal(resolveBubbleListMaxHeight({ room: 400, bubbleBottoms: FOUR_BUBBLES }), 156);
16
+ });
17
+
18
+ test("the height budget still wins when it is tighter than the count cap", () => {
19
+ assert.equal(resolveBubbleListMaxHeight({ room: 120, bubbleBottoms: FOUR_BUBBLES }), 120);
20
+ });
21
+
22
+ test("the minimum height floor applies to both budgets", () => {
23
+ assert.equal(resolveBubbleListMaxHeight({ room: 40, bubbleBottoms: FOUR_BUBBLES }), 96);
24
+ assert.equal(resolveBubbleListMaxHeight({ room: 40, bubbleBottoms: [48] }), 96);
25
+ });
26
+
27
+ test("the room is rounded to whole pixels", () => {
28
+ assert.equal(resolveBubbleListMaxHeight({ room: 150.6, bubbleBottoms: [48] }), 151);
29
+ });
30
+
31
+ test("maxVisible and minHeight are configurable", () => {
32
+ assert.equal(
33
+ resolveBubbleListMaxHeight({ room: 400, bubbleBottoms: FOUR_BUBBLES, maxVisible: 2 }),
34
+ 102
35
+ );
36
+ assert.equal(
37
+ resolveBubbleListMaxHeight({ room: 40, bubbleBottoms: [48], minHeight: 32 }),
38
+ 40
39
+ );
40
+ });
41
+
42
+ test("garbage measurements fall back safely", () => {
43
+ // Unusable room → the floor keeps the list usable.
44
+ assert.equal(resolveBubbleListMaxHeight({ room: Number.NaN, bubbleBottoms: [48] }), 96);
45
+ // Unusable bottom at the cap index → no count cap, height budget alone.
46
+ assert.equal(
47
+ resolveBubbleListMaxHeight({ room: 400, bubbleBottoms: [48, 102, Number.NaN, 210] }),
48
+ 400
49
+ );
50
+ });
@@ -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
+ }
@@ -47,6 +47,16 @@ test("reflects the attach-bubbles checkbox state", () => {
47
47
  assert.equal(buildTrayMenu({ ...baseState, attachBubblesToTerminals: false }).find((i) => i.id === "attach_bubbles").checked, false);
48
48
  });
49
49
 
50
+ test("shows the update item only when a newer version is known", () => {
51
+ const withoutUpdate = buildTrayMenu(baseState);
52
+ assert.ok(!withoutUpdate.some((i) => i.id === "update"), "no update item by default");
53
+
54
+ const withUpdate = buildTrayMenu({ ...baseState, updateAvailable: { latestVersion: "9.9.9" } });
55
+ const item = withUpdate.find((i) => i.id === "update");
56
+ assert.ok(item, "update item appears when an update is known");
57
+ assert.ok(item.label.includes("9.9.9"), "label names the new version");
58
+ });
59
+
50
60
  test("uses the HAYA Pet brand in the tray hover text", () => {
51
61
  assert.equal(buildTrayTooltip(), "HAYA Pet");
52
62
  });