@hayasaka7/haya-pet 0.2.1 → 0.2.3

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.
@@ -0,0 +1,75 @@
1
+ name: CI
2
+
3
+ # Run code quality checks and the test suite on every push that touches code.
4
+ on:
5
+ push:
6
+ paths:
7
+ - "**/*.js"
8
+ - "**/*.mjs"
9
+ - "**/*.cjs"
10
+ - "package.json"
11
+ - "package-lock.json"
12
+ - ".github/workflows/ci.yml"
13
+ pull_request:
14
+ paths:
15
+ - "**/*.js"
16
+ - "**/*.mjs"
17
+ - "**/*.cjs"
18
+ - "package.json"
19
+ - "package-lock.json"
20
+ - ".github/workflows/ci.yml"
21
+
22
+ concurrency:
23
+ group: ci-${{ github.workflow }}-${{ github.ref }}
24
+ cancel-in-progress: true
25
+
26
+ permissions:
27
+ contents: read
28
+
29
+ jobs:
30
+ lint:
31
+ name: Code quality (ESLint)
32
+ runs-on: ubuntu-latest
33
+ steps:
34
+ - uses: actions/checkout@v4
35
+
36
+ - name: Set up Node.js
37
+ uses: actions/setup-node@v4
38
+ with:
39
+ node-version: 22
40
+ cache: npm
41
+
42
+ - name: Install dependencies
43
+ # Electron's binary isn't needed for linting or tests; skip the ~150 MB
44
+ # download so CI is fast and isn't at the mercy of the Electron CDN.
45
+ env:
46
+ ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
47
+ run: npm ci
48
+
49
+ - name: Run ESLint
50
+ run: npm run lint
51
+
52
+ test:
53
+ name: Tests (Node ${{ matrix.node }} on ${{ matrix.os }})
54
+ runs-on: ${{ matrix.os }}
55
+ strategy:
56
+ fail-fast: false
57
+ matrix:
58
+ os: [ubuntu-latest, windows-latest, macos-latest]
59
+ node: [20, 22]
60
+ steps:
61
+ - uses: actions/checkout@v4
62
+
63
+ - name: Set up Node.js
64
+ uses: actions/setup-node@v4
65
+ with:
66
+ node-version: ${{ matrix.node }}
67
+ cache: npm
68
+
69
+ - name: Install dependencies
70
+ env:
71
+ ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
72
+ run: npm ci
73
+
74
+ - name: Run the test suite
75
+ run: npm test
package/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@ 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.3]
11
+
12
+ ### Added
13
+ - **Drag-to-resize the pet.** Hovering the pet reveals a small grip at its
14
+ bottom-right corner; drag it to scale the pet between 0.5× and 2× (aspect
15
+ locked), double-click it to reset to 1×. The size persists across restarts,
16
+ like the pet position. Only the pet scales — session bubbles keep their
17
+ readable size and keep anchoring beside it.
18
+
19
+ ## [0.2.2]
20
+
21
+ ### Fixed
22
+ - **Session bubbles no longer reshuffle while sessions run.** Bubbles used to be
23
+ sorted by state urgency and latest activity, so every status change could move
24
+ a bubble up or down the stack mid-progress. They now stack by the time each
25
+ session **connected to the pet** — newest on top, first one at the bottom —
26
+ and that order stays fixed for the session's whole life. Urgency still shows
27
+ through each bubble's status icon, the collapsed-folder summary dot, and the
28
+ pet animation.
29
+
30
+ ### Internal
31
+ - **CI on every code push** — a new GitHub Actions workflow lints and runs the
32
+ test suite (Ubuntu + Windows + macOS, Node 20/22) for any push or PR touching
33
+ code.
34
+ - **ESLint adopted** (`npm run lint`, flat config); the few existing findings
35
+ were fixed with no behavior change.
36
+
10
37
  ## [0.2.1]
11
38
 
12
39
  ### Added
package/README.md CHANGED
@@ -38,8 +38,9 @@ Haya Pet watches all of them and presents one ambient interface:
38
38
  draggable, and position-persistent like a real desktop companion.
39
39
  - **Session bubbles** — one compact bubble per active session showing client,
40
40
  project, the latest activity, and a status icon (a spinning *working* circle, a
41
- green *done* check, a yellow *needs you*, or a red *failed* cross). A folder
42
- button beside the pet folds them away.
41
+ green *done* check, a yellow *needs you*, or a red *failed* cross). Bubbles stack
42
+ by connect time — the newest session on top — so the stack never reshuffles while
43
+ work is in progress. A folder button beside the pet folds them away.
43
44
 
44
45
  ## Features
45
46
 
@@ -47,6 +48,8 @@ Haya Pet watches all of them and presents one ambient interface:
47
48
  stays click-through outside the pet and bubbles.
48
49
  - 🖱️ **Click / double-click / drag** — click folds/unfolds the bubbles, double-click
49
50
  expands them, drag moves the pet (position persists; bubbles stay on-screen).
51
+ - 📏 **Resizable pet** — hover the pet and drag the corner grip to scale it
52
+ 0.5×–2× for your screen; double-click the grip to reset. The size persists.
50
53
  - 🟢 **Live session bubbles** with per-session status icons and a folder toggle.
51
54
  - 🧠 **Normalized state model** — every client maps to a shared state vocabulary
52
55
  (`thinking`, `running_tool`, `waiting_approval`, `reviewing`, `failed`, …).
@@ -223,6 +226,8 @@ frames so everything still works.
223
226
  | Single click | waves + folds/unfolds the session bubbles |
224
227
  | Double click | jumps + expands the bubbles |
225
228
  | Drag | moves the pet; position is saved (bubbles follow, always on-screen) |
229
+ | Drag corner grip | resizes the pet 0.5×–2× (grip appears on hover); size is saved |
230
+ | Double-click grip | resets the pet to its normal size |
226
231
  | Tray icon → menu | show/hide, display mode, sessions, pets, **reset position**, **Quit** |
227
232
 
228
233
  ## Stop / exit the pet
@@ -11,9 +11,10 @@ import { createProcessSnapshotLister } from "../../../../packages/platform-core/
11
11
  import { getDefaultPaths } from "../../../../packages/platform-core/src/paths.js";
12
12
  import { getPlatformCapabilities } from "../../../../packages/platform-core/src/capabilities.js";
13
13
  import { buildBubbleViews } from "../../../../packages/session-core/src/bubble-view.js";
14
+ import { clampScale } from "../../../../packages/pet-core/src/pet-scale.js";
14
15
  import { buildPetWindowOptions, PET_SIZE } from "./window-options.js";
15
16
  import { resolveSavedPosition } from "./display-manager.js";
16
- import { setSelectedPet, updateGlobalPetPosition } from "./position-store.js";
17
+ import { getPetScale, setPetScale, setSelectedPet, updateGlobalPetPosition } from "./position-store.js";
17
18
  import { buildTrayMenu } from "./tray-menu.js";
18
19
  import { createStateFile } from "./state-file.js";
19
20
  import { discoverPets } from "./pet-loader.js";
@@ -43,6 +44,8 @@ let runtime;
43
44
  let currentWorkArea;
44
45
  let currentDisplayId;
45
46
  let petLocal = { x: 0, y: 0 };
47
+ // User-chosen pet scale (resize grip); the pet occupies PET_SIZE × petScale.
48
+ let petScale = 1;
46
49
  let approvalWatch;
47
50
 
48
51
  // Electron singleton: a second launch forwards to the running instance.
@@ -58,6 +61,7 @@ if (!app.requestSingleInstanceLock()) {
58
61
 
59
62
  async function bootstrap() {
60
63
  positionState = await stateFile.load();
64
+ petScale = clampScale(getPetScale(positionState));
61
65
  pets = await discoverPets(paths.petSearchPaths);
62
66
 
63
67
  // Clients fire no event at the moment the user ACCEPTS a permission prompt
@@ -164,9 +168,17 @@ function createPetWindow() {
164
168
  });
165
169
  }
166
170
 
171
+ function scaledPetSize() {
172
+ return {
173
+ width: Math.round(PET_SIZE.width * petScale),
174
+ height: Math.round(PET_SIZE.height * petScale)
175
+ };
176
+ }
177
+
167
178
  function clampPetLocal(local) {
168
- const maxX = Math.max(0, (currentWorkArea?.width ?? PET_SIZE.width) - PET_SIZE.width);
169
- const maxY = Math.max(0, (currentWorkArea?.height ?? PET_SIZE.height) - PET_SIZE.height);
179
+ const size = scaledPetSize();
180
+ const maxX = Math.max(0, (currentWorkArea?.width ?? size.width) - size.width);
181
+ const maxY = Math.max(0, (currentWorkArea?.height ?? size.height) - size.height);
170
182
  return {
171
183
  x: Math.min(Math.max(local.x ?? 0, 0), maxX),
172
184
  y: Math.min(Math.max(local.y ?? 0, 0), maxY)
@@ -274,6 +286,15 @@ function registerRendererHandlers() {
274
286
  persistPetPosition();
275
287
  return petLocal;
276
288
  });
289
+
290
+ // Resize grip released (or double-clicked to reset): store the new scale and
291
+ // re-clamp the position so a grown pet never sticks out of the work area.
292
+ ipcMain.handle("haya-pet:save-pet-scale", async (_event, scale) => {
293
+ petScale = clampScale(scale);
294
+ petLocal = clampPetLocal(petLocal);
295
+ persistPetPosition();
296
+ return petScale;
297
+ });
277
298
  }
278
299
 
279
300
  function buildSessionPayload() {
@@ -302,7 +323,8 @@ function sendPetConfig() {
302
323
  ? { manifest: selected.manifest, spritesheetUrl: selected.spritesheetUrl }
303
324
  : undefined,
304
325
  overlayMode: capabilities.transparentOverlay === "required" ? "transparent-overlay" : "fallback-window",
305
- petPosition: petLocal
326
+ petPosition: petLocal,
327
+ petScale
306
328
  });
307
329
  }
308
330
 
@@ -318,13 +340,15 @@ function persistPetPosition() {
318
340
 
319
341
  // Store the pet's absolute on-screen top-left so it can be restored on the
320
342
  // right display, mapping the in-window position back to screen coordinates.
321
- positionState = updateGlobalPetPosition(positionState, {
343
+ // The persisted box is the *scaled* size, so display restore clamps correctly.
344
+ const size = scaledPetSize();
345
+ positionState = setPetScale(updateGlobalPetPosition(positionState, {
322
346
  x: currentWorkArea.x + petLocal.x,
323
347
  y: currentWorkArea.y + petLocal.y,
324
- width: PET_SIZE.width,
325
- height: PET_SIZE.height,
348
+ width: size.width,
349
+ height: size.height,
326
350
  displayId: currentDisplayId
327
- });
351
+ }), petScale);
328
352
 
329
353
  // Debounce disk writes during drag (positionSaveDebounce, plan section 27).
330
354
  clearTimeout(persistTimer);
@@ -348,9 +372,10 @@ function focusPet() {
348
372
  function resetPosition() {
349
373
  // Drop the pet back to the bottom-right corner of its work area.
350
374
  const margin = 24;
375
+ const size = scaledPetSize();
351
376
  petLocal = clampPetLocal({
352
- x: (currentWorkArea?.width ?? PET_SIZE.width) - PET_SIZE.width - margin,
353
- y: (currentWorkArea?.height ?? PET_SIZE.height) - PET_SIZE.height - margin
377
+ x: (currentWorkArea?.width ?? size.width) - size.width - margin,
378
+ y: (currentWorkArea?.height ?? size.height) - size.height - margin
354
379
  });
355
380
  sendPetPosition();
356
381
  persistPetPosition();
@@ -5,6 +5,7 @@ const { contextBridge, ipcRenderer } = require("electron");
5
5
  contextBridge.exposeInMainWorld("aiPet", {
6
6
  listSessions: () => ipcRenderer.invoke("haya-pet:list-sessions"),
7
7
  savePetPosition: (local) => ipcRenderer.invoke("haya-pet:save-pet-position", local),
8
+ savePetScale: (scale) => ipcRenderer.invoke("haya-pet:save-pet-scale", scale),
8
9
  setMouseIgnore: (ignore) => ipcRenderer.send("haya-pet:set-mouse-ignore", ignore),
9
10
  onConfig: (handler) => ipcRenderer.on("haya-pet:config", (_event, config) => handler(config)),
10
11
  onSessions: (handler) => ipcRenderer.on("haya-pet:sessions", (_event, payload) => handler(payload)),
@@ -7,8 +7,12 @@
7
7
  <link rel="stylesheet" href="styles.css" />
8
8
  </head>
9
9
  <body>
10
- <!-- Layer 1: the global pet overlay. -->
11
- <canvas id="pet-canvas" class="interactive" width="192" height="208"></canvas>
10
+ <!-- Layer 1: the global pet overlay. The wrapper carries the position so the
11
+ hover-revealed resize grip travels with the canvas. -->
12
+ <div id="pet">
13
+ <canvas id="pet-canvas" class="interactive" width="192" height="208"></canvas>
14
+ <div id="pet-resize-grip" class="resize-grip interactive" title="Drag to resize · double-click to reset"></div>
15
+ </div>
12
16
 
13
17
  <!-- Layer 2: ongoing-session progress bubbles + folder toggle. -->
14
18
  <div id="bubbles" class="bubbles"></div>
@@ -3,6 +3,7 @@
3
3
  // is the browser glue that draws frames and forwards pointer gestures.
4
4
 
5
5
  import { CELL_HEIGHT, CELL_WIDTH, getFrameRect } from "../../../../packages/pet-core/src/atlas.js";
6
+ import { clampScale, DEFAULT_SCALE, resolveScaleFromDrag } from "../../../../packages/pet-core/src/pet-scale.js";
6
7
  import { getActionDurationMs, getFrameAt } from "../../../../packages/pet-core/src/animator.js";
7
8
  import {
8
9
  clearDragAction,
@@ -19,10 +20,15 @@ import { createInteractionController } from "./interaction-controller.js";
19
20
  import { createBubbleList } from "./session-bubbles.js";
20
21
 
21
22
  const bridge = window.aiPet;
23
+ const petEl = document.getElementById("pet");
22
24
  const canvas = document.getElementById("pet-canvas");
25
+ const gripEl = document.getElementById("pet-resize-grip");
23
26
  const ctx = canvas.getContext("2d");
24
27
  const panelEl = document.getElementById("bubbles");
25
28
 
29
+ // The sprite's natural cell size; the canvas is this times the user's scale.
30
+ const BASE_SIZE = Object.freeze({ width: CELL_WIDTH, height: CELL_HEIGHT });
31
+
26
32
  const controller = createInteractionController({
27
33
  // Click is deferred so a double-click never also fires a wave. Clicking the
28
34
  // pet folds/unfolds the session bubbles; double-click forces them open.
@@ -50,6 +56,8 @@ let previousSessionStates = {};
50
56
  // The pet lives at this work-area-relative position inside the full-screen
51
57
  // overlay window; dragging moves it via CSS (the window never moves).
52
58
  let petLocal = { x: 0, y: 0 };
59
+ // User-chosen pet scale (resize grip), persisted like the position.
60
+ let petScale = DEFAULT_SCALE;
53
61
  // Linger bookkeeping so a finished session's bubble stays ~2s before vanishing.
54
62
  let lingerState = {};
55
63
  let lingerTimer;
@@ -60,6 +68,10 @@ function setupPet(config) {
60
68
  manifest = config.pet.manifest;
61
69
  }
62
70
 
71
+ if (config?.petScale !== undefined) {
72
+ applyPetScale(config.petScale);
73
+ }
74
+
63
75
  if (config?.petPosition) {
64
76
  applyPetPosition(config.petPosition);
65
77
  }
@@ -75,11 +87,21 @@ function setupPet(config) {
75
87
 
76
88
  function applyPetPosition(pos) {
77
89
  petLocal = clampPetLocal(pos);
78
- canvas.style.left = `${petLocal.x}px`;
79
- canvas.style.top = `${petLocal.y}px`;
90
+ petEl.style.left = `${petLocal.x}px`;
91
+ petEl.style.top = `${petLocal.y}px`;
80
92
  placePanel();
81
93
  }
82
94
 
95
+ // Resizes the canvas's pixel size, so each frame re-renders at the new scale
96
+ // (no CSS stretching). Everything that reads canvas.width/height — drag
97
+ // clamping, panel placement — adapts automatically.
98
+ function applyPetScale(scale) {
99
+ petScale = clampScale(scale);
100
+ canvas.width = Math.round(BASE_SIZE.width * petScale);
101
+ canvas.height = Math.round(BASE_SIZE.height * petScale);
102
+ applyPetPosition(petLocal);
103
+ }
104
+
83
105
  function clampPetLocal(pos) {
84
106
  const maxX = Math.max(0, window.innerWidth - canvas.width);
85
107
  const maxY = Math.max(0, window.innerHeight - canvas.height);
@@ -148,13 +170,13 @@ function draw(action, frameIndex) {
148
170
  function drawPlaceholder(action, frameIndex) {
149
171
  ctx.fillStyle = "rgba(110, 168, 254, 0.85)";
150
172
  ctx.beginPath();
151
- ctx.roundRect ? ctx.roundRect(16, 16, CELL_WIDTH - 32, CELL_HEIGHT - 32, 16) : ctx.rect(16, 16, CELL_WIDTH - 32, CELL_HEIGHT - 32);
173
+ ctx.roundRect ? ctx.roundRect(16, 16, canvas.width - 32, canvas.height - 32, 16) : ctx.rect(16, 16, canvas.width - 32, canvas.height - 32);
152
174
  ctx.fill();
153
175
  ctx.fillStyle = "#001233";
154
176
  ctx.font = "14px system-ui";
155
177
  ctx.textAlign = "center";
156
- ctx.fillText(action, CELL_WIDTH / 2, CELL_HEIGHT / 2);
157
- ctx.fillText(`frame ${frameIndex}`, CELL_WIDTH / 2, CELL_HEIGHT / 2 + 20);
178
+ ctx.fillText(action, canvas.width / 2, canvas.height / 2);
179
+ ctx.fillText(`frame ${frameIndex}`, canvas.width / 2, canvas.height / 2 + 20);
158
180
  }
159
181
 
160
182
  function playOneShot(action) {
@@ -188,6 +210,50 @@ canvas.addEventListener("pointerup", (event) => {
188
210
  }
189
211
  });
190
212
 
213
+ // --- Resize grip: drag to scale the pet, double-click to reset ---
214
+
215
+ let resizeDrag; // { startScale, startPointer } while a grip drag is active
216
+
217
+ gripEl.addEventListener("pointerdown", (event) => {
218
+ gripEl.setPointerCapture(event.pointerId);
219
+ gripEl.classList.add("active");
220
+ resizeDrag = {
221
+ startScale: petScale,
222
+ startPointer: { x: event.clientX, y: event.clientY }
223
+ };
224
+ });
225
+
226
+ gripEl.addEventListener("pointermove", (event) => {
227
+ if (!resizeDrag) {
228
+ return;
229
+ }
230
+ applyPetScale(resolveScaleFromDrag({
231
+ startScale: resizeDrag.startScale,
232
+ startPointer: resizeDrag.startPointer,
233
+ pointer: { x: event.clientX, y: event.clientY },
234
+ baseSize: BASE_SIZE
235
+ }));
236
+ });
237
+
238
+ gripEl.addEventListener("pointerup", () => {
239
+ if (!resizeDrag) {
240
+ return;
241
+ }
242
+ resizeDrag = undefined;
243
+ gripEl.classList.remove("active");
244
+ bridge?.savePetScale?.(petScale);
245
+ });
246
+
247
+ gripEl.addEventListener("pointercancel", () => {
248
+ resizeDrag = undefined;
249
+ gripEl.classList.remove("active");
250
+ });
251
+
252
+ gripEl.addEventListener("dblclick", () => {
253
+ applyPetScale(DEFAULT_SCALE);
254
+ bridge?.savePetScale?.(DEFAULT_SCALE);
255
+ });
256
+
191
257
  // Re-clamp and re-place when the work area changes (display/resolution change).
192
258
  window.addEventListener("resize", () => {
193
259
  applyPetPosition(petLocal);
@@ -247,6 +313,12 @@ let mouseIgnored;
247
313
  let lastPointer = { x: -1, y: -1 };
248
314
 
249
315
  function refreshMouseIgnore(x, y) {
316
+ // While the grip is captured, the pointer can briefly leave it (the pet only
317
+ // approximately tracks the diagonal); flipping click-through mid-drag would
318
+ // drop the pointerup, so hold interaction until the drag ends.
319
+ if (resizeDrag) {
320
+ return;
321
+ }
250
322
  if (!Number.isFinite(x) || !Number.isFinite(y)) {
251
323
  return;
252
324
  }
@@ -30,10 +30,14 @@ body {
30
30
  its right. The window itself is large + transparent, and pointer events are
31
31
  forwarded so only the marked .interactive regions catch clicks — everything
32
32
  else passes through to the desktop (see pet-window.js). */
33
- #pet-canvas {
33
+ #pet {
34
34
  position: absolute;
35
35
  left: 0;
36
36
  top: 0;
37
+ }
38
+
39
+ #pet-canvas {
40
+ display: block;
37
41
  image-rendering: pixelated;
38
42
  cursor: grab;
39
43
  }
@@ -42,6 +46,26 @@ body {
42
46
  cursor: grabbing;
43
47
  }
44
48
 
49
+ /* Resize grip: a small corner triangle that fades in while hovering the pet.
50
+ Dragging it scales the pet (aspect-locked); double-click resets to 1×. */
51
+ .resize-grip {
52
+ position: absolute;
53
+ right: 0;
54
+ bottom: 0;
55
+ width: 14px;
56
+ height: 14px;
57
+ cursor: nwse-resize;
58
+ background: linear-gradient(135deg, transparent 50%, rgba(255, 255, 255, 0.75) 50%);
59
+ border-radius: 2px;
60
+ opacity: 0;
61
+ transition: opacity 0.15s ease;
62
+ }
63
+
64
+ #pet:hover .resize-grip,
65
+ .resize-grip.active {
66
+ opacity: 1;
67
+ }
68
+
45
69
  /* Panel container is inert; its children opt back into pointer events so the
46
70
  gaps between bubbles stay click-through. Its left/top are set by JS
47
71
  (panel-placement) so it always sits beside the pet, fully on-screen. The
@@ -121,7 +121,7 @@ function renderComposer(bubble, replyMode, bridge) {
121
121
  return composer;
122
122
  }
123
123
 
124
- function renderControls(controlsPromise, bubble, bridge) {
124
+ function renderControls(controlsPromise, _bubble, _bridge) {
125
125
  const wrap = document.createElement("div");
126
126
  wrap.className = "controls";
127
127
 
@@ -0,0 +1,32 @@
1
+ import js from "@eslint/js";
2
+ import globals from "globals";
3
+
4
+ export default [
5
+ {
6
+ ignores: [
7
+ "node_modules/**",
8
+ "native/**",
9
+ "tmp/**",
10
+ "docs/**",
11
+ ".gax/**",
12
+ ],
13
+ },
14
+ js.configs.recommended,
15
+ {
16
+ files: ["**/*.js", "**/*.mjs", "**/*.cjs"],
17
+ languageOptions: {
18
+ ecmaVersion: "latest",
19
+ sourceType: "module",
20
+ globals: {
21
+ ...globals.node,
22
+ ...globals.browser,
23
+ },
24
+ },
25
+ rules: {
26
+ "no-unused-vars": [
27
+ "error",
28
+ { argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
29
+ ],
30
+ },
31
+ },
32
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hayasaka7/haya-pet",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "description": "Generic AI CLI pet runtime foundation.",
6
6
  "license": "MIT",
@@ -17,6 +17,7 @@
17
17
  "haya-pet": "apps/cli/src/haya-pet.js"
18
18
  },
19
19
  "scripts": {
20
+ "lint": "eslint .",
20
21
  "test": "node test/run-tests.mjs"
21
22
  },
22
23
  "workspaces": [
@@ -31,5 +32,10 @@
31
32
  },
32
33
  "engines": {
33
34
  "node": ">=16.20.0"
35
+ },
36
+ "devDependencies": {
37
+ "@eslint/js": "^10.0.1",
38
+ "eslint": "^10.4.1",
39
+ "globals": "^17.6.0"
34
40
  }
35
41
  }
@@ -66,6 +66,23 @@ export function getSelectedPetId(state) {
66
66
  return state?.globalPet?.selectedPetId;
67
67
  }
68
68
 
69
+ // Pet display scale (resize grip). Range clamping lives in pet-core's
70
+ // pet-scale module; this layer only stores and validates the raw value.
71
+ export function setPetScale(state, scale) {
72
+ return {
73
+ ...state,
74
+ globalPet: {
75
+ ...state.globalPet,
76
+ scale
77
+ }
78
+ };
79
+ }
80
+
81
+ export function getPetScale(state) {
82
+ const scale = state?.globalPet?.scale;
83
+ return Number.isFinite(scale) ? scale : undefined;
84
+ }
85
+
69
86
  export function serializePositionState(state) {
70
87
  return `${JSON.stringify(state, null, 2)}\n`;
71
88
  }
@@ -2,7 +2,11 @@ import assert from "node:assert/strict";
2
2
  import { test } from "../../../test/harness.mjs";
3
3
  import {
4
4
  createDefaultPositionState,
5
+ getPetScale,
5
6
  getSelectedPetId,
7
+ parsePositionState,
8
+ serializePositionState,
9
+ setPetScale,
6
10
  setSelectedPet
7
11
  } from "../src/state.js";
8
12
 
@@ -34,3 +38,36 @@ test("getSelectedPetId tolerates missing state", () => {
34
38
  assert.equal(getSelectedPetId(undefined), undefined);
35
39
  assert.equal(getSelectedPetId({}), undefined);
36
40
  });
41
+
42
+ test("setPetScale stores the scale immutably", () => {
43
+ const state = createDefaultPositionState();
44
+ const next = setPetScale(state, 1.5);
45
+
46
+ assert.equal(getPetScale(next), 1.5);
47
+ assert.equal(getPetScale(state), undefined);
48
+ assert.notEqual(next.globalPet, state.globalPet);
49
+ });
50
+
51
+ test("setPetScale preserves other globalPet fields", () => {
52
+ const state = { ...createDefaultPositionState(), globalPet: { open: true, x: 10, y: 20, manual: true } };
53
+ const next = setPetScale(state, 0.75);
54
+
55
+ assert.equal(next.globalPet.x, 10);
56
+ assert.equal(next.globalPet.manual, true);
57
+ assert.equal(next.globalPet.scale, 0.75);
58
+ });
59
+
60
+ test("getPetScale returns undefined for missing or invalid values", () => {
61
+ assert.equal(getPetScale(undefined), undefined);
62
+ assert.equal(getPetScale(createDefaultPositionState()), undefined);
63
+ assert.equal(getPetScale(setPetScale(createDefaultPositionState(), Number.NaN)), undefined);
64
+ assert.equal(getPetScale({ globalPet: { scale: "big" } }), undefined);
65
+ });
66
+
67
+ test("scale survives a serialize/parse round-trip and old files parse without it", () => {
68
+ const withScale = parsePositionState(serializePositionState(setPetScale(createDefaultPositionState(), 1.25)));
69
+ assert.equal(getPetScale(withScale), 1.25);
70
+
71
+ const legacy = parsePositionState(JSON.stringify({ globalPet: { open: true, x: 1, y: 2 } }));
72
+ assert.equal(getPetScale(legacy), undefined);
73
+ });
@@ -256,7 +256,6 @@ async function runObservedCommand({
256
256
  registered = true;
257
257
  if (pendingState) {
258
258
  emitState(pendingState);
259
- pendingState = undefined;
260
259
  }
261
260
 
262
261
  await sendProtocolMessage(send, { type: "heartbeat", sessionId, updatedAt: now() });
@@ -0,0 +1,34 @@
1
+ // Pure math for the pet resize grip: the user drags the pet's bottom-right
2
+ // corner to scale the sprite between MIN_SCALE and MAX_SCALE, aspect locked.
3
+
4
+ export const MIN_SCALE = 0.5;
5
+ export const MAX_SCALE = 2;
6
+ export const DEFAULT_SCALE = 1;
7
+
8
+ export function clampScale(value) {
9
+ if (!Number.isFinite(value)) {
10
+ return DEFAULT_SCALE;
11
+ }
12
+
13
+ return Math.min(Math.max(value, MIN_SCALE), MAX_SCALE);
14
+ }
15
+
16
+ // Maps a grip drag to the next scale: each axis implies a scale from how far
17
+ // the pointer moved relative to the pet's base size, and the two are averaged
18
+ // so the pet follows the pointer along the diagonal while staying aspect-locked.
19
+ export function resolveScaleFromDrag({ startScale, startPointer, pointer, baseSize } = {}) {
20
+ const safeStart = clampScale(startScale);
21
+
22
+ if (
23
+ !Number.isFinite(startPointer?.x) || !Number.isFinite(startPointer?.y) ||
24
+ !Number.isFinite(pointer?.x) || !Number.isFinite(pointer?.y) ||
25
+ !Number.isFinite(baseSize?.width) || !Number.isFinite(baseSize?.height) ||
26
+ baseSize.width <= 0 || baseSize.height <= 0
27
+ ) {
28
+ return safeStart;
29
+ }
30
+
31
+ const scaleX = safeStart + (pointer.x - startPointer.x) / baseSize.width;
32
+ const scaleY = safeStart + (pointer.y - startPointer.y) / baseSize.height;
33
+ return clampScale((scaleX + scaleY) / 2);
34
+ }
@@ -0,0 +1,94 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import {
4
+ clampScale,
5
+ DEFAULT_SCALE,
6
+ MAX_SCALE,
7
+ MIN_SCALE,
8
+ resolveScaleFromDrag
9
+ } from "../src/pet-scale.js";
10
+
11
+ const baseSize = { width: 192, height: 208 };
12
+
13
+ test("scale constants define a sane range around the default", () => {
14
+ assert.equal(DEFAULT_SCALE, 1);
15
+ assert.ok(MIN_SCALE < DEFAULT_SCALE);
16
+ assert.ok(MAX_SCALE > DEFAULT_SCALE);
17
+ });
18
+
19
+ test("clampScale keeps in-range values and clamps out-of-range ones", () => {
20
+ assert.equal(clampScale(1.25), 1.25);
21
+ assert.equal(clampScale(MIN_SCALE), MIN_SCALE);
22
+ assert.equal(clampScale(MAX_SCALE), MAX_SCALE);
23
+ assert.equal(clampScale(0.01), MIN_SCALE);
24
+ assert.equal(clampScale(99), MAX_SCALE);
25
+ });
26
+
27
+ test("clampScale falls back to the default for invalid input", () => {
28
+ assert.equal(clampScale(undefined), DEFAULT_SCALE);
29
+ assert.equal(clampScale(null), DEFAULT_SCALE);
30
+ assert.equal(clampScale(Number.NaN), DEFAULT_SCALE);
31
+ assert.equal(clampScale("big"), DEFAULT_SCALE);
32
+ });
33
+
34
+ test("dragging the grip outward grows the scale", () => {
35
+ const next = resolveScaleFromDrag({
36
+ startScale: 1,
37
+ startPointer: { x: 500, y: 500 },
38
+ pointer: { x: 500 + baseSize.width / 2, y: 500 + baseSize.height / 2 },
39
+ baseSize
40
+ });
41
+
42
+ assert.equal(next, 1.5);
43
+ });
44
+
45
+ test("dragging the grip inward shrinks the scale", () => {
46
+ const next = resolveScaleFromDrag({
47
+ startScale: 1,
48
+ startPointer: { x: 500, y: 500 },
49
+ pointer: { x: 500 - baseSize.width / 4, y: 500 - baseSize.height / 4 },
50
+ baseSize
51
+ });
52
+
53
+ assert.equal(next, 0.75);
54
+ });
55
+
56
+ test("a zero drag keeps the starting scale", () => {
57
+ const next = resolveScaleFromDrag({
58
+ startScale: 1.3,
59
+ startPointer: { x: 10, y: 10 },
60
+ pointer: { x: 10, y: 10 },
61
+ baseSize
62
+ });
63
+
64
+ assert.equal(next, 1.3);
65
+ });
66
+
67
+ test("drag results are clamped to the scale range", () => {
68
+ const grown = resolveScaleFromDrag({
69
+ startScale: 1.8,
70
+ startPointer: { x: 0, y: 0 },
71
+ pointer: { x: 1000, y: 1000 },
72
+ baseSize
73
+ });
74
+ const shrunk = resolveScaleFromDrag({
75
+ startScale: 0.6,
76
+ startPointer: { x: 1000, y: 1000 },
77
+ pointer: { x: 0, y: 0 },
78
+ baseSize
79
+ });
80
+
81
+ assert.equal(grown, MAX_SCALE);
82
+ assert.equal(shrunk, MIN_SCALE);
83
+ });
84
+
85
+ test("invalid drag input falls back to the clamped starting scale", () => {
86
+ const next = resolveScaleFromDrag({
87
+ startScale: 1.2,
88
+ startPointer: { x: Number.NaN, y: 0 },
89
+ pointer: { x: 50, y: 50 },
90
+ baseSize
91
+ });
92
+
93
+ assert.equal(next, 1.2);
94
+ });
@@ -1,5 +1,4 @@
1
1
  import { mapAiStateToPetAction } from "../../pet-core/src/atlas.js";
2
- import { getSessionPriorityRank } from "./priority.js";
3
2
  import { buildSessionSummary, buildStatusLabel, formatElapsed } from "./summaries.js";
4
3
 
5
4
  // Collapses the full AI-state vocabulary into the four progress kinds the
@@ -53,17 +52,21 @@ export function buildBubbleViews(sessions, now = Date.now(), options = {}) {
53
52
  return sessions
54
53
  .filter(Boolean)
55
54
  .slice()
56
- .sort(compareByPriority)
55
+ .sort(compareByConnectTime)
57
56
  .map((session) => buildBubbleView(session, now, options));
58
57
  }
59
58
 
60
- function compareByPriority(left, right) {
61
- const rankDelta = getSessionPriorityRank(left) - getSessionPriorityRank(right);
62
- if (rankDelta !== 0) {
63
- return rankDelta;
59
+ // Bubbles stack by connect time — the newest session on top, the first one at
60
+ // the bottom and never reshuffle while sessions are in progress. State
61
+ // urgency only drives the collapsed-folder dot and the pet animation, not the
62
+ // list order.
63
+ function compareByConnectTime(left, right) {
64
+ const startedDelta = numeric(right.startedAt) - numeric(left.startedAt);
65
+ if (startedDelta !== 0) {
66
+ return startedDelta;
64
67
  }
65
68
 
66
- return numeric(right.updatedAt) - numeric(left.updatedAt);
69
+ return String(left.sessionId).localeCompare(String(right.sessionId));
67
70
  }
68
71
 
69
72
  function safePetAction(state) {
@@ -28,15 +28,40 @@ test("builds a bubble view model with label, summary, action, and elapsed", () =
28
28
  assert.equal(view.elapsedLabel, "1m 4s");
29
29
  });
30
30
 
31
- test("orders bubbles by session priority then recency", () => {
31
+ test("stacks bubbles by connect time, newest on top, so they never reshuffle mid-session", () => {
32
32
  const sessions = [
33
- { ...baseSession, sessionId: "sess_idle", state: "idle", updatedAt: 9_000 },
34
- { ...baseSession, sessionId: "sess_wait", state: "waiting_approval", updatedAt: 4_000 },
35
- { ...baseSession, sessionId: "sess_run", state: "running_tool", updatedAt: 8_000 }
33
+ { ...baseSession, sessionId: "sess_third", state: "waiting_approval", startedAt: 3_000, updatedAt: 4_000 },
34
+ { ...baseSession, sessionId: "sess_first", state: "idle", startedAt: 1_000, updatedAt: 9_000 },
35
+ { ...baseSession, sessionId: "sess_second", state: "running_tool", startedAt: 2_000, updatedAt: 8_000 }
36
36
  ];
37
37
 
38
38
  const views = buildBubbleViews(sessions, 10_000);
39
- assert.deepEqual(views.map((view) => view.sessionId), ["sess_wait", "sess_run", "sess_idle"]);
39
+ assert.deepEqual(views.map((view) => view.sessionId), ["sess_third", "sess_second", "sess_first"]);
40
+ });
41
+
42
+ test("keeps bubble order stable when states and activity change", () => {
43
+ const before = [
44
+ { ...baseSession, sessionId: "sess_first", state: "running_tool", startedAt: 1_000, updatedAt: 2_000 },
45
+ { ...baseSession, sessionId: "sess_second", state: "idle", startedAt: 2_000, updatedAt: 2_500 }
46
+ ];
47
+ // Later, the second session becomes urgent and more recently active.
48
+ const after = [
49
+ { ...baseSession, sessionId: "sess_first", state: "idle", startedAt: 1_000, updatedAt: 3_000 },
50
+ { ...baseSession, sessionId: "sess_second", state: "waiting_approval", startedAt: 2_000, updatedAt: 9_000 }
51
+ ];
52
+
53
+ const order = (sessions) => buildBubbleViews(sessions, 10_000).map((view) => view.sessionId);
54
+ assert.deepEqual(order(before), order(after));
55
+ });
56
+
57
+ test("breaks connect-time ties by session id for a deterministic order", () => {
58
+ const sessions = [
59
+ { ...baseSession, sessionId: "sess_b", startedAt: 1_000 },
60
+ { ...baseSession, sessionId: "sess_a", startedAt: 1_000 }
61
+ ];
62
+
63
+ const views = buildBubbleViews(sessions, 10_000);
64
+ assert.deepEqual(views.map((view) => view.sessionId), ["sess_a", "sess_b"]);
40
65
  });
41
66
 
42
67
  test("marks the selected/pinned session", () => {