@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.
- package/CHANGELOG.md +48 -0
- package/README.md +19 -1
- package/apps/cli/src/haya-pet.js +72 -4
- package/apps/cli/test/haya-pet.test.mjs +68 -0
- package/apps/companion/src/main/bubble-list-viewport.js +26 -0
- package/apps/companion/src/main/index.js +52 -2
- package/apps/companion/src/main/tray-menu.js +5 -0
- package/apps/companion/src/renderer/pet-window.js +5 -2
- package/apps/companion/src/renderer/session-bubbles.js +153 -25
- package/apps/companion/src/renderer/styles.css +19 -0
- package/apps/companion/test/bubble-list-viewport.test.mjs +50 -0
- package/apps/companion/test/session-bubbles.test.mjs +347 -0
- package/apps/companion/test/tray-menu.test.mjs +10 -0
- package/docs/architecture.md +8 -0
- package/docs/known-issues.md +33 -0
- package/docs/troubleshooting.md +2 -0
- package/package.json +1 -1
- package/packages/app-state/src/state.js +4 -1
- package/packages/app-state/src/update-check.js +173 -0
- package/packages/app-state/test/update-check.test.mjs +227 -0
- package/packages/cli-core/src/deadline.js +23 -0
- package/packages/cli-core/src/run-state.js +31 -11
- package/packages/cli-core/test/deadline.test.mjs +29 -0
- package/packages/cli-core/test/run-state.test.mjs +41 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
});
|