@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
|
-
|
|
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
|
|
31
|
-
|
|
47
|
+
if (lastBubbles.length === 0) {
|
|
48
|
+
clearContainer();
|
|
49
|
+
onRender?.();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
32
52
|
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/docs/architecture.md
CHANGED
|
@@ -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
|