@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.
- package/.github/workflows/ci.yml +75 -0
- package/CHANGELOG.md +27 -0
- package/README.md +7 -2
- package/apps/companion/src/main/index.js +35 -10
- package/apps/companion/src/main/preload.cjs +1 -0
- package/apps/companion/src/renderer/index.html +6 -2
- package/apps/companion/src/renderer/pet-window.js +77 -5
- package/apps/companion/src/renderer/styles.css +25 -1
- package/apps/companion/src/renderer/task-talk-window.js +1 -1
- package/assets/fallback-pet/spritesheet.webp +0 -0
- package/eslint.config.js +32 -0
- package/package.json +7 -1
- package/packages/app-state/src/state.js +17 -0
- package/packages/app-state/test/state.test.mjs +37 -0
- package/packages/cli-core/src/run-command.js +0 -1
- package/packages/pet-core/src/pet-scale.js +34 -0
- package/packages/pet-core/test/pet-scale.test.mjs +94 -0
- package/packages/session-core/src/bubble-view.js +10 -7
- package/packages/session-core/test/bubble-view.test.mjs +30 -5
|
@@ -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).
|
|
42
|
-
|
|
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
|
|
169
|
-
const
|
|
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
|
-
|
|
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:
|
|
325
|
-
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 ??
|
|
353
|
-
y: (currentWorkArea?.height ??
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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,
|
|
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,
|
|
157
|
-
ctx.fillText(`frame ${frameIndex}`,
|
|
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
|
|
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,
|
|
124
|
+
function renderControls(controlsPromise, _bubble, _bridge) {
|
|
125
125
|
const wrap = document.createElement("div");
|
|
126
126
|
wrap.className = "controls";
|
|
127
127
|
|
|
Binary file
|
package/eslint.config.js
ADDED
|
@@ -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.
|
|
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
|
+
});
|
|
@@ -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(
|
|
55
|
+
.sort(compareByConnectTime)
|
|
57
56
|
.map((session) => buildBubbleView(session, now, options));
|
|
58
57
|
}
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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("
|
|
31
|
+
test("stacks bubbles by connect time, newest on top, so they never reshuffle mid-session", () => {
|
|
32
32
|
const sessions = [
|
|
33
|
-
{ ...baseSession, sessionId: "
|
|
34
|
-
{ ...baseSession, sessionId: "
|
|
35
|
-
{ ...baseSession, sessionId: "
|
|
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), ["
|
|
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", () => {
|