@hayasaka7/haya-pet 0.3.12 → 0.3.14

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,32 @@ 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.14]
11
+
12
+ ### Changed
13
+ - **The session-bubble folder now opens and closes with a smooth animation.**
14
+ Folding or unfolding the bubbles used to pop them in and out instantly; the
15
+ panel now grows out of (and shrinks back into) the folder button's corner with
16
+ a light scale-and-fade — a macOS-popover feel. The transform-origin follows the
17
+ panel's open direction and alignment, so it always springs from whichever corner
18
+ sits against the button. The animation is GPU-composited (transform + opacity
19
+ only, no reflow), so the folder button and the placement math stay put; the list
20
+ also **stays mounted while collapsed**, which preserves both the scroll position
21
+ and the live status spinner across a toggle, and it drops out of hit-testing once
22
+ hidden so the pixel-precise click-through overlay still ignores it. A
23
+ reduced-motion preference (`prefers-reduced-motion`) snaps the panel open/closed
24
+ instead of animating.
25
+
26
+ ## [0.3.13]
27
+
28
+ ### Added
29
+ - **Right-clicking the pet now opens the tray menu.** A right-click used to behave
30
+ like a left-click (wave + fold/unfold the bubbles). It now pops up the same
31
+ menu as the system-tray icon — Show/Hide Pet, Active Sessions, Installed Pets,
32
+ Reset Position, update, Quit — which is far more discoverable than the tray icon
33
+ (often hidden in the Windows overflow). The menu is built from the one pure tray
34
+ model, so both entry points always match. Left-click behaviour is unchanged.
35
+
10
36
  ## [0.3.12]
11
37
 
12
38
  ### Fixed
package/README.md CHANGED
@@ -220,6 +220,7 @@ non-observe mode keeps terminal input native.
220
220
  |---|---|
221
221
  | Single click | Wave and fold or unfold session bubbles. |
222
222
  | Double click | Jump and expand session bubbles. |
223
+ | Right click | Open the same menu as the tray icon (sessions, pets, reset, updates, quit). |
223
224
  | Drag | Move the pet; position is saved. |
224
225
  | Drag corner grip | Resize from 0.5x to 2x; size is saved. |
225
226
  | Double-click grip | Reset to normal size. |
@@ -291,17 +291,15 @@ function loadTrayIcon() {
291
291
  return fileIcon.isEmpty() ? nativeImage.createFromDataURL(TRAY_ICON_DATA_URL) : fileIcon;
292
292
  }
293
293
 
294
- function refreshTrayMenu() {
295
- if (!tray) {
296
- return;
297
- }
298
-
294
+ // Builds the native menu template from the pure tray model. Shared by the tray
295
+ // icon and the pet's right-click context menu so both stay identical.
296
+ function buildTrayMenuTemplate() {
299
297
  const sessions = (runtime?.listSessions() ?? []).map((session) => ({
300
298
  sessionId: session.sessionId,
301
299
  label: `${session.clientDisplayName} · ${session.projectName}`
302
300
  }));
303
301
 
304
- const template = buildTrayMenu({
302
+ return buildTrayMenu({
305
303
  petVisible: petWindow?.isVisible() ?? true,
306
304
  displayMode: positionState.settings.displayMode,
307
305
  attachBubblesToTerminals: positionState.settings.attachBubblesToTerminals,
@@ -310,8 +308,14 @@ function refreshTrayMenu() {
310
308
  pets: pets.map((pet) => ({ id: pet.manifest.id, name: pet.manifest.name })),
311
309
  updateAvailable
312
310
  }).map(toElectronMenuItem);
311
+ }
312
+
313
+ function refreshTrayMenu() {
314
+ if (!tray) {
315
+ return;
316
+ }
313
317
 
314
- tray.setContextMenu(Menu.buildFromTemplate(template));
318
+ tray.setContextMenu(Menu.buildFromTemplate(buildTrayMenuTemplate()));
315
319
  }
316
320
 
317
321
  function toElectronMenuItem(item) {
@@ -425,6 +429,16 @@ function registerRendererHandlers() {
425
429
  }
426
430
  });
427
431
 
432
+ // Right-click on the pet pops up the same menu as the tray icon (built from
433
+ // the one pure tray model), since the tray icon is often buried in the Windows
434
+ // overflow. Fire-and-forget: the native menu is shown and dispatched in main.
435
+ ipcMain.on("haya-pet:show-pet-menu", () => {
436
+ if (!petWindow || petWindow.isDestroyed()) {
437
+ return;
438
+ }
439
+ Menu.buildFromTemplate(buildTrayMenuTemplate()).popup({ window: petWindow });
440
+ });
441
+
428
442
  // The pet moves within the overlay (CSS), so the renderer reports its new
429
443
  // work-area-relative position instead of moving the window.
430
444
  ipcMain.handle("haya-pet:save-pet-position", async (_event, local) => {
@@ -7,6 +7,7 @@ contextBridge.exposeInMainWorld("aiPet", {
7
7
  savePetPosition: (local) => ipcRenderer.invoke("haya-pet:save-pet-position", local),
8
8
  savePetScale: (scale) => ipcRenderer.invoke("haya-pet:save-pet-scale", scale),
9
9
  setMouseIgnore: (ignore) => ipcRenderer.send("haya-pet:set-mouse-ignore", ignore),
10
+ showPetMenu: () => ipcRenderer.send("haya-pet:show-pet-menu"),
10
11
  onConfig: (handler) => ipcRenderer.on("haya-pet:config", (_event, config) => handler(config)),
11
12
  onSessions: (handler) => ipcRenderer.on("haya-pet:sessions", (_event, payload) => handler(payload)),
12
13
  onPetPosition: (handler) => ipcRenderer.on("haya-pet:pet-position", (_event, pos) => handler(pos)),
@@ -192,6 +192,11 @@ function playOneShot(action) {
192
192
  // --- Pointer interaction (click vs drag distinction lives in the controller) ---
193
193
 
194
194
  canvas.addEventListener("pointerdown", (event) => {
195
+ // Only the primary button drives click/drag; right-click pops the context menu
196
+ // (handled below) and must not also fire a wave/toggle or start a drag.
197
+ if (event.button !== 0) {
198
+ return;
199
+ }
195
200
  canvas.setPointerCapture(event.pointerId);
196
201
  // Hold click-through off for the whole press: a drag swaps to the running
197
202
  // frames, whose opaque pixels differ from the grabbed one, so re-running the
@@ -211,6 +216,11 @@ canvas.addEventListener("pointermove", (event) => {
211
216
  });
212
217
 
213
218
  canvas.addEventListener("pointerup", (event) => {
219
+ // Mirror pointerdown: ignore non-primary releases so a right-click never feeds
220
+ // the click controller (its pointerDown was skipped anyway).
221
+ if (event.button !== 0) {
222
+ return;
223
+ }
214
224
  // Click / double-click are delivered asynchronously via onAction; only the
215
225
  // synchronous drag-end is handled here.
216
226
  petPressed = false;
@@ -227,6 +237,15 @@ canvas.addEventListener("pointercancel", () => {
227
237
  animationState = clearDragAction(animationState);
228
238
  });
229
239
 
240
+ // Right-click the pet to open the same menu as the tray icon. The native menu is
241
+ // built and shown in the main process; preventDefault stops Electron's default
242
+ // context menu. Only fires over opaque pet pixels (transparent areas are
243
+ // click-through and the right-click falls to the desktop, like a left-click).
244
+ canvas.addEventListener("contextmenu", (event) => {
245
+ event.preventDefault();
246
+ bridge?.showPetMenu?.();
247
+ });
248
+
230
249
  // --- Resize grip: drag to scale the pet, double-click to reset ---
231
250
 
232
251
  let resizeDrag; // { startScale, startPointer } while a grip drag is active
@@ -56,26 +56,23 @@ export function createBubbleList(container, { collapsed = false, onRender } = {}
56
56
  }
57
57
  updateFolderButton(folderButtonEl, lastBubbles, isCollapsed);
58
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);
76
- }
77
- reconcileBubbles(listEl, lastBubbles);
59
+ // The list stays mounted whether open or collapsed so the collapse can
60
+ // animate out (a macOS-popover shrink+fade, see styles.css) instead of
61
+ // vanishing; this also preserves the scroll position and the spinner across
62
+ // a toggle. The `collapsed` class drives the transition and, once faded,
63
+ // drops the list from hit-testing (pointer-events + visibility), so the
64
+ // click-through overlay ignores it — hence it also sheds the `interactive`
65
+ // marker while collapsed.
66
+ if (!listEl) {
67
+ // The list itself must be pointer-active (not just the bubbles): with more
68
+ // than three sessions it scrolls, and the scrollbar + the gaps between
69
+ // bubbles belong to the list element if it stayed click-through, wheel/
70
+ // drag there would fall through to the desktop.
71
+ listEl = document.createElement("div");
72
+ container.appendChild(listEl);
78
73
  }
74
+ reconcileBubbles(listEl, lastBubbles);
75
+ listEl.className = isCollapsed ? "bubble-list collapsed" : "bubble-list interactive";
79
76
 
80
77
  // Let the host reposition the panel now that its size is known. This must
81
78
  // happen BEFORE the scroll restore below: the host's placement pass is what
@@ -84,7 +81,9 @@ export function createBubbleList(container, { collapsed = false, onRender } = {}
84
81
  // scrollTop assigned to it would clamp back to 0.
85
82
  onRender?.();
86
83
 
87
- if (listEl) {
84
+ // Restoring scroll only matters while the list is visible; a collapsed list
85
+ // keeps the scrollTop it had, so it reopens exactly where the user left it.
86
+ if (!isCollapsed) {
88
87
  applyListScroll(listEl, scrollTargetSessionId, lastScrollTop);
89
88
  lastScrollTop = listEl.scrollTop;
90
89
  }
@@ -150,6 +150,25 @@ body {
150
150
  max-width: 300px;
151
151
  overflow-y: auto;
152
152
  pointer-events: auto;
153
+ /* Open/close grows out of the folder-button corner (transform-origin set per
154
+ open direction below), macOS-popover style. transform + opacity are
155
+ GPU-composited and don't reflow, so the button and placement math stay put. */
156
+ transform-origin: top left;
157
+ opacity: 1;
158
+ transform: scale(1);
159
+ transition: opacity 0.16s ease, transform 0.2s cubic-bezier(0.32, 0.72, 0, 1);
160
+ }
161
+
162
+ /* Collapsed: shrink toward the anchor corner and fade out, then drop out of
163
+ hit-testing (visibility + pointer-events) once the fade finishes so the
164
+ click-through overlay ignores it. Re-expanding reverses this instantly. */
165
+ .bubble-list.collapsed {
166
+ opacity: 0;
167
+ transform: scale(0.9);
168
+ pointer-events: none;
169
+ visibility: hidden;
170
+ transition: opacity 0.16s ease, transform 0.18s cubic-bezier(0.32, 0.72, 0, 1),
171
+ visibility 0s 0.18s;
153
172
  }
154
173
 
155
174
  /* Scrolling is by design (at most three bubbles are visible at once), so give
@@ -176,6 +195,13 @@ body {
176
195
  .bubble-list[data-open-align="left"] { left: 0; right: auto; }
177
196
  .bubble-list[data-open-align="right"] { right: 0; left: auto; }
178
197
 
198
+ /* Anchor the grow/shrink to whichever corner sits against the folder button, so
199
+ the panel appears to spring out of it (and collapse back into it). */
200
+ .bubble-list[data-open-direction="down"][data-open-align="left"] { transform-origin: top left; }
201
+ .bubble-list[data-open-direction="down"][data-open-align="right"] { transform-origin: top right; }
202
+ .bubble-list[data-open-direction="up"][data-open-align="left"] { transform-origin: bottom left; }
203
+ .bubble-list[data-open-direction="up"][data-open-align="right"] { transform-origin: bottom right; }
204
+
179
205
  .bubble {
180
206
  display: flex;
181
207
  align-items: flex-start;
@@ -270,3 +296,13 @@ body {
270
296
  @keyframes status-spin {
271
297
  to { transform: rotate(360deg); }
272
298
  }
299
+
300
+ /* Respect a reduced-motion preference: snap the folder open/closed instead of
301
+ animating the caret and the panel. */
302
+ @media (prefers-reduced-motion: reduce) {
303
+ .caret,
304
+ .bubble-list,
305
+ .bubble-list.collapsed {
306
+ transition: none;
307
+ }
308
+ }
@@ -110,7 +110,7 @@ test("updates the folder button in place (count and urgency dot)", () => {
110
110
  }
111
111
  });
112
112
 
113
- test("preserves scroll position across collapse and expand", () => {
113
+ test("keeps the list mounted but marked collapsed, preserving scroll across a toggle", () => {
114
114
  const restoreDocument = installFakeDocument();
115
115
  try {
116
116
  const container = new FakeElement("div");
@@ -120,11 +120,20 @@ test("preserves scroll position across collapse and expand", () => {
120
120
  listView.render(bubbles);
121
121
  findList(container).scrollTop = 100;
122
122
 
123
+ // Collapsing keeps the node mounted (so it can animate out) but marks it
124
+ // collapsed and drops the `interactive` marker so hit-testing ignores it.
123
125
  listView.toggle();
124
- assert.equal(findList(container), undefined);
125
- listView.toggle();
126
+ const collapsed = findList(container);
127
+ assert.ok(collapsed);
128
+ assert.ok(collapsed.className.split(" ").includes("collapsed"));
129
+ assert.ok(!collapsed.className.split(" ").includes("interactive"));
126
130
 
127
- assert.equal(findList(container).scrollTop, 100);
131
+ listView.toggle();
132
+ const expanded = findList(container);
133
+ assert.ok(!expanded.className.split(" ").includes("collapsed"));
134
+ assert.ok(expanded.className.split(" ").includes("interactive"));
135
+ // The scrollTop rode through untouched since the node was never rebuilt.
136
+ assert.equal(expanded.scrollTop, 100);
128
137
  } finally {
129
138
  restoreDocument();
130
139
  }
@@ -254,7 +263,7 @@ function makeBubbles(sessionIds) {
254
263
  }
255
264
 
256
265
  function findList(container) {
257
- return container.children.find((child) => child.className === "bubble-list interactive");
266
+ return container.children.find((child) => child.className.split(" ").includes("bubble-list"));
258
267
  }
259
268
 
260
269
  function findButton(container) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hayasaka7/haya-pet",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "type": "module",
5
5
  "description": "Generic AI CLI pet runtime foundation.",
6
6
  "keywords": [