@hayasaka7/haya-pet 0.2.7 → 0.3.0

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,31 @@ 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.3.0]
11
+
12
+ ### Fixed
13
+ - **A fresh install now shows a real pet.** The package has always shipped a
14
+ ready-to-use pet (`assets/fallback-pet`), but discovery only scanned the
15
+ user's pet folders (`~/.codex/pets`, `~/.haya-pet/pets`), so a new user with
16
+ no pets got a blue "dev placeholder" box and an empty pet list instead. The
17
+ bundled pet is now composed into discovery as a last resort — appended after
18
+ any of the user's own pets (which still win and stay the default) and deduped
19
+ by id — so the overlay always renders a real character out of the box.
20
+
21
+ ## [0.2.8]
22
+
23
+ ### Fixed
24
+ - **Scrolling the session panel no longer fights you.** With four or more
25
+ sessions the bubble panel scrolls (introduced in 0.2.7), but two bugs made it
26
+ unusable: the scroll position snapped back to the top on every status update,
27
+ and a wheel scroll or scrollbar drag would "disconnect" mid-gesture and need
28
+ restarting. Both came from the panel rebuilding its entire DOM on each refresh
29
+ (every session push plus a 2 s linger tick). The panel now renders
30
+ incrementally — the list and each session's bubble persist across updates and
31
+ are mutated in place — so the scroll position holds and a gesture stays
32
+ attached to its element. The status spinner also no longer restarts on every
33
+ refresh.
34
+
10
35
  ## [0.2.7]
11
36
 
12
37
  ### Fixed
@@ -14,7 +14,7 @@ import { watchCodexGuardianReviews as defaultWatchCodexGuardianReviews } from ".
14
14
  import { ensureCompanionConnection } from "../../../packages/cli-core/src/companion-launcher.js";
15
15
  import { createIpcClient as defaultCreateIpcClient } from "../../../packages/daemon-core/src/ipc-server.js";
16
16
  import { getDefaultPaths } from "../../../packages/platform-core/src/paths.js";
17
- import { discoverPets as defaultDiscoverPets } from "../../../packages/pet-core/src/discovery.js";
17
+ import { discoverPetsWithFallback as defaultDiscoverPets } from "../../../packages/pet-core/src/discovery.js";
18
18
  import { createStateFile as defaultCreateStateFile } from "../../../packages/app-state/src/state-file.js";
19
19
  import { getSelectedPetId, setSelectedPet, getHooksEnabled, setHooksEnabled } from "../../../packages/app-state/src/state.js";
20
20
  import { checkForUpdate, UPDATE_COMMAND } from "../../../packages/app-state/src/update-check.js";
@@ -18,7 +18,7 @@ import { resolveSavedPosition } from "./display-manager.js";
18
18
  import { getPetScale, setPetScale, setSelectedPet, updateGlobalPetPosition } from "./position-store.js";
19
19
  import { buildTrayMenu, buildTrayTooltip } from "./tray-menu.js";
20
20
  import { createStateFile } from "./state-file.js";
21
- import { discoverPets } from "./pet-loader.js";
21
+ import { discoverPetsWithFallback } from "./pet-loader.js";
22
22
  import { checkForUpdate, UPDATE_PAGE_URL } from "../../../../packages/app-state/src/update-check.js";
23
23
 
24
24
  const STALE_SWEEP_INTERVAL_MS = 10_000;
@@ -67,7 +67,7 @@ if (!app.requestSingleInstanceLock()) {
67
67
  async function bootstrap() {
68
68
  positionState = await stateFile.load();
69
69
  petScale = clampScale(getPetScale(positionState));
70
- pets = await discoverPets(paths.petSearchPaths);
70
+ pets = await discoverPetsWithFallback(paths.petSearchPaths);
71
71
 
72
72
  // Clients fire no event at the moment the user ACCEPTS a permission prompt
73
73
  // (only denial/finish are observable), so a waiting_approval session would
@@ -1,2 +1,6 @@
1
1
  // Re-exported from pet-core so the companion and CLI share one discovery path.
2
- export { discoverPets, loadPetFromDir } from "../../../../packages/pet-core/src/discovery.js";
2
+ export {
3
+ discoverPets,
4
+ discoverPetsWithFallback,
5
+ loadPetFromDir
6
+ } from "../../../../packages/pet-core/src/discovery.js";
@@ -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.3.0",
4
4
  "type": "module",
5
5
  "description": "Generic AI CLI pet runtime foundation.",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  import { readdir as fsReaddir, readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import { pathToFileURL } from "node:url";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
4
  import { parsePetManifest } from "./manifest.js";
5
5
 
6
6
  // Scans pet search paths for Codex-compatible pets. Each pet is a directory
@@ -28,6 +28,33 @@ export async function discoverPets(searchPaths = [], deps = {}) {
28
28
  return pets;
29
29
  }
30
30
 
31
+ // discoverPets only scans the user's pet folders (~/.codex/pets, ~/.haya-pet/pets),
32
+ // so a fresh install with no pets renders the dev placeholder instead of a real
33
+ // character. The package ships a ready-to-use pet; this composes it in as a
34
+ // last resort — appended after the user's pets so a real install still wins for
35
+ // the default selection, and deduped by id so a user who copied it in isn't
36
+ // shown two. The bundled directory is injectable so this stays testable.
37
+ export async function discoverPetsWithFallback(searchPaths = [], deps = {}) {
38
+ const pets = await discoverPets(searchPaths, deps);
39
+ const fallbackDir = deps.fallbackPetDir ?? getBundledFallbackPetDir();
40
+ const fallback = await loadPetFromDir(fallbackDir, deps);
41
+
42
+ if (!fallback || pets.some((pet) => pet.manifest.id === fallback.manifest.id)) {
43
+ return pets;
44
+ }
45
+
46
+ return [...pets, fallback];
47
+ }
48
+
49
+ // Absolute path to the bundled fallback pet, resolved relative to this module so
50
+ // it works the same for global installs, npm link, and source checkouts. The
51
+ // asset lives at the package root (assets/fallback-pet); this file sits three
52
+ // levels below it (packages/pet-core/src).
53
+ export function getBundledFallbackPetDir() {
54
+ const here = dirname(fileURLToPath(import.meta.url));
55
+ return join(here, "..", "..", "..", "assets", "fallback-pet");
56
+ }
57
+
31
58
  export async function loadPetFromDir(petDir, deps = {}) {
32
59
  const { readFile, stat } = resolveFs(deps);
33
60
  const manifestPath = join(petDir, "pet.json");
@@ -1,7 +1,12 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { join } from "node:path";
3
3
  import { test } from "../../../test/harness.mjs";
4
- import { discoverPets, loadPetFromDir } from "../src/discovery.js";
4
+ import {
5
+ discoverPets,
6
+ discoverPetsWithFallback,
7
+ getBundledFallbackPetDir,
8
+ loadPetFromDir
9
+ } from "../src/discovery.js";
5
10
 
6
11
  function manifest(id, name) {
7
12
  return JSON.stringify({ id, name, spritesheet: "spritesheet.webp" });
@@ -91,3 +96,60 @@ test("returns undefined for an invalid manifest", async () => {
91
96
  test("tolerates missing search directories", async () => {
92
97
  assert.deepEqual(await discoverPets([join("does-not-exist")], fakeFs()), []);
93
98
  });
99
+
100
+ test("falls back to the bundled pet when the user has no pets", async () => {
101
+ const fallbackDir = join("bundled", "fallback-pet");
102
+ const fs = fakeFs({
103
+ files: {
104
+ [join(fallbackDir, "pet.json")]: manifest("fallback-pet", "Fallback"),
105
+ [join(fallbackDir, "spritesheet.webp")]: "x"
106
+ }
107
+ });
108
+
109
+ const pets = await discoverPetsWithFallback([join("empty")], { ...fs, fallbackPetDir: fallbackDir });
110
+ assert.equal(pets.length, 1);
111
+ assert.equal(pets[0].manifest.id, "fallback-pet");
112
+ });
113
+
114
+ test("appends the bundled pet after the user's own pets", async () => {
115
+ const root = join("petsdir");
116
+ const fallbackDir = join("bundled", "fallback-pet");
117
+ const fs = fakeFs({
118
+ dirs: { [root]: ["cat"] },
119
+ files: {
120
+ [join(root, "cat", "pet.json")]: manifest("cat", "Cat"),
121
+ [join(root, "cat", "spritesheet.webp")]: "x",
122
+ [join(fallbackDir, "pet.json")]: manifest("fallback-pet", "Fallback"),
123
+ [join(fallbackDir, "spritesheet.webp")]: "x"
124
+ }
125
+ });
126
+
127
+ const pets = await discoverPetsWithFallback([root], { ...fs, fallbackPetDir: fallbackDir });
128
+ assert.deepEqual(pets.map((pet) => pet.manifest.id), ["cat", "fallback-pet"]);
129
+ });
130
+
131
+ test("does not duplicate the bundled pet when the user already installed it", async () => {
132
+ const root = join("petsdir");
133
+ const fallbackDir = join("bundled", "fallback-pet");
134
+ const fs = fakeFs({
135
+ dirs: { [root]: ["fallback-pet"] },
136
+ files: {
137
+ [join(root, "fallback-pet", "pet.json")]: manifest("fallback-pet", "User Copy"),
138
+ [join(root, "fallback-pet", "spritesheet.webp")]: "x",
139
+ [join(fallbackDir, "pet.json")]: manifest("fallback-pet", "Bundled"),
140
+ [join(fallbackDir, "spritesheet.webp")]: "x"
141
+ }
142
+ });
143
+
144
+ const pets = await discoverPetsWithFallback([root], { ...fs, fallbackPetDir: fallbackDir });
145
+ assert.equal(pets.length, 1);
146
+ assert.equal(pets[0].manifest.name, "User Copy");
147
+ });
148
+
149
+ // Guards both the package-relative path resolution and that the asset actually
150
+ // ships: if either breaks, a fresh install renders the dev placeholder again.
151
+ test("ships a bundled fallback pet that loads from disk", async () => {
152
+ const pet = await loadPetFromDir(getBundledFallbackPetDir());
153
+ assert.ok(pet, "bundled fallback pet should load from the shipped package");
154
+ assert.equal(pet.manifest.id, "fallback-pet");
155
+ });