@hayasaka7/haya-pet 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,40 @@ 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.8]
11
+
12
+ ### Fixed
13
+ - **An interrupt or denial in one Claude Code session no longer leaks into a
14
+ concurrent, idle one.** The transcript watcher discovered its file by "newest
15
+ `.jsonl` by mtime in the project dir", so two Claude sessions in the same folder
16
+ (one project dir, one transcript each) could make an idle session's watcher lock
17
+ onto a *busy* session's transcript — then read its `[Request interrupted by
18
+ user]` marker (or a denial) and report the wrong pet as *interrupted*. Each
19
+ session's watcher now pins to its own transcript via the `transcript_path` Claude
20
+ includes in every hook payload (recorded as a per-session link by the `haya-pet
21
+ state` reporter) instead of guessing; until that link exists it idles rather than
22
+ locking onto another session's file. Codex shares the same discovery shape and is
23
+ tracked as a known issue.
24
+ - **The pet no longer disappears when the display layout changes.** The overlay
25
+ window's bounds were set once at creation to span one display's work area and
26
+ never re-homed, so unplugging a monitor, changing resolution/DPI, docking or
27
+ undocking, or waking from sleep could strand it off-screen (or on a display that
28
+ no longer exists) — the pet vanished while the process kept running, and neither
29
+ **Show/Hide Pet** (which only flips visibility) nor **Reset Position** (which only
30
+ moved the sprite *inside* the window) brought it back. The companion now re-homes
31
+ the overlay onto a valid display on `screen` display add/remove/metrics-change and
32
+ on resume from sleep, and **Reset Position** / **Show Pet** / relaunch re-home the
33
+ window itself. Automatic re-homes preserve the preferred display, so the pet
34
+ returns there when the monitor comes back.
35
+
36
+ ## [0.3.7]
37
+
38
+ ### Changed
39
+ - **The tray menu no longer shows state-only controls.** Hidden **Display Mode**
40
+ and **Attach Bubbles to Terminals** until those settings have real runtime
41
+ behavior. **Active Sessions** stays visible while session actions continue in a
42
+ separate workflow.
43
+
10
44
  ## [0.3.6]
11
45
 
12
46
  ### Fixed
@@ -38,6 +72,12 @@ All notable changes to HAYA Pet are documented here. This project adheres to
38
72
  watcher, so a resumed main rollout could be rejected before the guardian trunk
39
73
  was matched to it. The guardian watcher now uses the same fresh-mtime + wrapped
40
74
  cwd rule for resumed main sessions before following the guardian review trunk.
75
+ - **Codex hook review is one-time again.** Codex stores approved hook hashes in
76
+ the generated `$CODEX_HOME/haya-pet.config.toml` profile under `[hooks.state]`.
77
+ The injector used to rewrite the whole managed profile on every launch, deleting
78
+ that trust state and forcing Codex to ask for hook review every time. The
79
+ injector now preserves Codex's hook trust tables while regenerating the HAYA
80
+ hook definitions.
41
81
 
42
82
  ### Added
43
83
  - **`HAYA_PET_DAEMON_DEBUG` diagnostic.** When set to a file path, the companion
package/README.md CHANGED
@@ -136,7 +136,7 @@ success or failure briefly, then fades.
136
136
  | Global pet | Reacts to the highest-priority session and can be dragged anywhere. |
137
137
  | Session bubbles | One bubble per running AI session, ordered by connect time. |
138
138
  | Folder button | Folds the bubbles away when you want a cleaner desktop. |
139
- | Tray menu | Show/hide, display mode, installed pets, reset position, and quit. |
139
+ | Tray menu | Show/hide, active sessions, installed pets, reset position, updates, and quit. |
140
140
  | Resize grip | Hover the pet, drag the corner, and keep the size you like. |
141
141
 
142
142
  ## Screenshots
@@ -144,7 +144,7 @@ success or failure briefly, then fades.
144
144
  | | |
145
145
  |---|---|
146
146
  | **The global pet** - reacting to the highest-priority session.<br>![Pet overlay](docs/screenshots/pet-overlay.png) | **Session bubbles** - one per active session, with status icons.<br>![Session bubbles](docs/screenshots/session-bubbles.png) |
147
- | **Folder collapsed** - bubbles tucked away beside the pet.<br>![Folder collapsed](docs/screenshots/folder-collapsed.png) | **Tray menu** - show/hide, pets, reset position, quit.<br>![Tray menu](docs/screenshots/tray-menu.png) |
147
+ | **Folder collapsed** - bubbles tucked away beside the pet.<br>![Folder collapsed](docs/screenshots/folder-collapsed.png) | **Tray menu** - show/hide, sessions, pets, reset position, quit.<br>![Tray menu](docs/screenshots/tray-menu.png) |
148
148
 
149
149
  ## Supported Clients
150
150
 
@@ -217,7 +217,7 @@ non-observe mode keeps terminal input native.
217
217
  | Drag | Move the pet; position is saved. |
218
218
  | Drag corner grip | Resize from 0.5x to 2x; size is saved. |
219
219
  | Double-click grip | Reset to normal size. |
220
- | Tray icon | Open menu for display, sessions, pets, reset, and quit. |
220
+ | Tray icon | Open menu for sessions, pets, reset, updates, and quit. |
221
221
 
222
222
  ## Commands
223
223
 
@@ -5,7 +5,8 @@ import { randomUUID } from "node:crypto";
5
5
  import { dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { runGenericCommand as defaultRunGenericCommand } from "../../../packages/cli-core/src/run-command.js";
8
- import { parseStateArgs, runStateCommand } from "../../../packages/cli-core/src/run-state.js";
8
+ import { parseStateArgs, runStateCommand, readHookTranscriptPathFromStdin } from "../../../packages/cli-core/src/run-state.js";
9
+ import { removeSessionTranscriptLink } from "../../../packages/cli-core/src/session-transcript-link.js";
9
10
  import { injectClaudeHooks as defaultInjectClaudeHooks } from "../../../packages/cli-core/src/claude-hook-injection.js";
10
11
  import { injectCodexHooks as defaultInjectCodexHooks } from "../../../packages/cli-core/src/codex-hook-injection.js";
11
12
  import { watchClaudeTranscript as defaultWatchClaudeTranscript } from "../../../packages/cli-core/src/claude-transcript-watcher.js";
@@ -353,6 +354,12 @@ async function runRunCommand(parsed, dependencies) {
353
354
  childEnv = { ...env, HAYA_PET_SESSION_ID: sessionId };
354
355
  cleanup = injected.cleanup;
355
356
 
357
+ // The transcript watcher pins to THIS session's transcript via the
358
+ // session->transcript link the `haya-pet state` reporter writes from the hook
359
+ // payload. sessionDir is where that link lives; resolve it defensively so a
360
+ // missing home dir (tests) never breaks the run.
361
+ const sessionDir = resolveSessionDir(dependencies, env);
362
+
356
363
  // Claude fires NO hook when the user manually denies a permission, so the
357
364
  // pet would stay stuck on "waiting for approval". Tail the session transcript
358
365
  // (ground truth) and clear to idle the moment a denial is recorded — never on
@@ -361,6 +368,8 @@ async function runRunCommand(parsed, dependencies) {
361
368
  cwd,
362
369
  homeDir: dependencies.homeDir,
363
370
  startedAt: now(),
371
+ sessionId,
372
+ sessionDir,
364
373
  onDenial: (event) => {
365
374
  hookDebugLog(env, now, { source: "transcript", event: "denied", state: "idle", toolUseId: event?.toolUseId });
366
375
  messageSender
@@ -392,7 +401,10 @@ async function runRunCommand(parsed, dependencies) {
392
401
  .catch(() => {});
393
402
  }
394
403
  });
395
- stopWatcher = watcher.stop;
404
+ stopWatcher = () => {
405
+ watcher.stop();
406
+ removeSessionTranscriptLink({ sessionDir, sessionId });
407
+ };
396
408
  }
397
409
 
398
410
  // Codex: no `--settings` equivalent, so inject a stable profile and add
@@ -624,6 +636,21 @@ function createConfigStateFile(dependencies) {
624
636
  return createStateFile({ statePath: paths.statePath });
625
637
  }
626
638
 
639
+ // Where per-session transcript links live. Resolved defensively: if no home dir
640
+ // is available (some tests), return undefined and the watcher falls back to its
641
+ // legacy discovery instead of crashing the run.
642
+ function resolveSessionDir(dependencies, env) {
643
+ try {
644
+ return getDefaultPaths({
645
+ platform: dependencies.platform,
646
+ env,
647
+ homeDir: dependencies.homeDir
648
+ }).sessionDir;
649
+ } catch {
650
+ return undefined;
651
+ }
652
+ }
653
+
627
654
  // Best-effort: mirror the reporter's HAYA_PET_HOOK_DEBUG log so transcript-driven
628
655
  // events (which don't go through `haya-pet state`) show up in the same trace.
629
656
  function hookDebugLog(env, now, entry) {
@@ -917,7 +944,7 @@ async function noopSend() {}
917
944
  async function noopClose() {}
918
945
 
919
946
  if (isDirectRun(import.meta.url, process.argv[1])) {
920
- main()
947
+ bootstrap()
921
948
  .catch((error) => {
922
949
  console.error(error.message);
923
950
  process.exitCode = 1;
@@ -930,6 +957,28 @@ if (isDirectRun(import.meta.url, process.argv[1])) {
930
957
  });
931
958
  }
932
959
 
960
+ // Real-process entry. For a `haya-pet state` invocation — which is ALWAYS a client
961
+ // hook child — read the hook payload from stdin once to learn this session's real
962
+ // transcript path, and hand it to the reporter so it can record the
963
+ // session->transcript link. Done here (not inside main/runStateCommand) so unit
964
+ // tests, and every other command that needs stdin passed through to its child
965
+ // (e.g. `run`), never touch stdin.
966
+ async function bootstrap() {
967
+ const argv = process.argv.slice(2);
968
+ const dependencies = {};
969
+ if (argv[0] === "state") {
970
+ try {
971
+ const transcriptPath = await readHookTranscriptPathFromStdin();
972
+ if (transcriptPath) {
973
+ dependencies.transcriptPath = transcriptPath;
974
+ }
975
+ } catch {
976
+ // a missing/garbled payload just means no binding this time — never fatal
977
+ }
978
+ }
979
+ return main(argv, dependencies);
980
+ }
981
+
933
982
  function isDirectRun(moduleUrl, scriptPath) {
934
983
  if (!scriptPath) {
935
984
  return false;
@@ -22,6 +22,41 @@ export function resolveSavedPosition(savedPosition, displays, size = DEFAULT_SIZ
22
22
  };
23
23
  }
24
24
 
25
+ // Resolve where the overlay window should sit RIGHT NOW: which display's work
26
+ // area it spans, plus where the pet sprite sits inside it (work-area-relative).
27
+ // Shared by initial placement and by re-homing after a display change (monitor
28
+ // unplugged, resolution/DPI change, dock/undock, sleep→resume). Re-homing is what
29
+ // stops the overlay from being stranded off-screen on a display that no longer
30
+ // exists — the bug where the pet "vanishes" but the process is alive and neither
31
+ // Show/Hide nor Reset (which only move the sprite inside the window) recover it.
32
+ export function resolveOverlayPlacement({ savedPosition, displays, petSize = DEFAULT_SIZE } = {}) {
33
+ const saved = resolveSavedPosition(savedPosition, displays, petSize);
34
+ // saved.displayId always names a display that currently exists (resolveSavedPosition
35
+ // falls back to primary/first when the saved one is gone), so this re-homes a
36
+ // stranded position onto a valid display.
37
+ const display = findDisplayForSavedPosition({ displayId: saved.displayId }, displays);
38
+ const workArea = display.workArea ?? display.bounds;
39
+ const petLocal = clampLocalToWorkArea(
40
+ { x: saved.x - workArea.x, y: saved.y - workArea.y },
41
+ workArea,
42
+ petSize
43
+ );
44
+
45
+ return { workArea, displayId: display.id, petLocal };
46
+ }
47
+
48
+ // Keep a pet's work-area-relative position inside the work area for a given pet
49
+ // size. Pure mirror of the companion's in-window clamp, so placement resolution
50
+ // can be unit-tested without Electron.
51
+ export function clampLocalToWorkArea(local, workArea, petSize = DEFAULT_SIZE) {
52
+ const maxX = Math.max(0, (workArea?.width ?? petSize.width) - petSize.width);
53
+ const maxY = Math.max(0, (workArea?.height ?? petSize.height) - petSize.height);
54
+ return {
55
+ x: clamp(local?.x ?? 0, 0, maxX),
56
+ y: clamp(local?.y ?? 0, 0, maxY)
57
+ };
58
+ }
59
+
25
60
  export function clampWindowBounds(bounds, display) {
26
61
  const workArea = display.workArea ?? display.bounds;
27
62
  const width = bounds.width ?? DEFAULT_SIZE.width;
@@ -1,4 +1,4 @@
1
- import { app, BrowserWindow, ipcMain, Menu, nativeImage, screen, shell, Tray } from "electron";
1
+ import { app, BrowserWindow, ipcMain, Menu, nativeImage, powerMonitor, screen, shell, Tray } from "electron";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { appendFileSync, readFileSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
@@ -14,7 +14,7 @@ import { getPlatformCapabilities } from "../../../../packages/platform-core/src/
14
14
  import { buildBubbleViews } from "../../../../packages/session-core/src/bubble-view.js";
15
15
  import { clampScale } from "../../../../packages/pet-core/src/pet-scale.js";
16
16
  import { buildPetWindowOptions, PET_SIZE } from "./window-options.js";
17
- import { resolveSavedPosition } from "./display-manager.js";
17
+ import { clampLocalToWorkArea, resolveOverlayPlacement } 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";
@@ -151,6 +151,7 @@ async function bootstrap() {
151
151
  createPetWindow();
152
152
  createTray();
153
153
  registerRendererHandlers();
154
+ registerDisplayWatchers();
154
155
  // Best-effort and cached in state.json (shared with the CLI's check, so at
155
156
  // most one registry request per day between them); never blocks startup.
156
157
  void detectUpdate();
@@ -171,16 +172,9 @@ async function bootstrap() {
171
172
  }
172
173
 
173
174
  function createPetWindow() {
174
- const displays = listDisplays();
175
- // Resolve the pet's saved on-screen position (pet-sized), which also tells us
176
- // which display it belongs on. The window then spans that display's work area.
177
- const saved = resolveSavedPosition(positionState.globalPet, displays);
178
- const display = displays.find((d) => d.id === saved.displayId)
179
- ?? displays.find((d) => d.primary)
180
- ?? displays[0];
181
- currentWorkArea = display.workArea ?? display.bounds;
182
- currentDisplayId = display.id;
183
- petLocal = clampPetLocal({ x: saved.x - currentWorkArea.x, y: saved.y - currentWorkArea.y });
175
+ // Resolve which display the pet belongs on and where it sits inside that
176
+ // display's work area; the window then spans that work area.
177
+ applyOverlayPlacement(resolveCurrentPlacement());
184
178
 
185
179
  const { browserWindow } = buildPetWindowOptions({ capabilities, bounds: currentWorkArea });
186
180
 
@@ -214,13 +208,69 @@ function scaledPetSize() {
214
208
  }
215
209
 
216
210
  function clampPetLocal(local) {
211
+ return clampLocalToWorkArea(local ?? petLocal, currentWorkArea, scaledPetSize());
212
+ }
213
+
214
+ // Resolve where the overlay should sit right now, given the saved position and the
215
+ // CURRENT set of displays. Used at startup and on every re-home.
216
+ function resolveCurrentPlacement() {
217
+ return resolveOverlayPlacement({
218
+ savedPosition: positionState.globalPet,
219
+ displays: listDisplays(),
220
+ petSize: scaledPetSize()
221
+ });
222
+ }
223
+
224
+ // Adopt a resolved placement into the module's window/pet state (does NOT move the
225
+ // BrowserWindow — callers decide whether to create or setBounds).
226
+ function applyOverlayPlacement(placement) {
227
+ currentWorkArea = placement.workArea;
228
+ currentDisplayId = placement.displayId;
229
+ petLocal = placement.petLocal;
230
+ }
231
+
232
+ // Re-home the overlay onto a currently-valid display and re-assert its bounds.
233
+ // A display change (monitor unplugged, resolution/DPI change, dock/undock,
234
+ // sleep→resume) can strand the window off-screen on a display that no longer
235
+ // exists: the pet "vanishes" while the process is alive, and neither Show/Hide
236
+ // nor Reset (which only move the sprite INSIDE the window) bring it back. This is
237
+ // the one operation that puts the window itself back on screen.
238
+ function rehomeOverlay({ recenter = false, persist = true } = {}) {
239
+ if (!petWindow || petWindow.isDestroyed()) {
240
+ return;
241
+ }
242
+
243
+ applyOverlayPlacement(resolveCurrentPlacement());
244
+ if (recenter) {
245
+ petLocal = cornerPetLocal();
246
+ }
247
+
248
+ try {
249
+ petWindow.setBounds(currentWorkArea);
250
+ } catch (error) {
251
+ // Setting bounds must never crash the overlay; the next display event retries.
252
+ console.error("overlay re-home failed:", error.message);
253
+ }
254
+ if (!petWindow.isVisible()) {
255
+ petWindow.show();
256
+ }
257
+ sendPetPosition();
258
+ // Automatic re-homes (display change / resume) deliberately do NOT persist, so
259
+ // the user's preferred display is preserved and the pet returns there once that
260
+ // display comes back. User-initiated re-homes (reset/show) persist as usual.
261
+ if (persist) {
262
+ persistPetPosition();
263
+ }
264
+ }
265
+
266
+ // Bottom-right corner of the current work area (the Reset target).
267
+ function cornerPetLocal() {
268
+ const margin = 24;
217
269
  const size = scaledPetSize();
218
- const maxX = Math.max(0, (currentWorkArea?.width ?? size.width) - size.width);
219
- const maxY = Math.max(0, (currentWorkArea?.height ?? size.height) - size.height);
220
- return {
221
- x: Math.min(Math.max(local.x ?? 0, 0), maxX),
222
- y: Math.min(Math.max(local.y ?? 0, 0), maxY)
223
- };
270
+ return clampPetLocal({
271
+ x: (currentWorkArea?.width ?? size.width) - size.width - margin,
272
+ y: (currentWorkArea?.height ?? size.height) - size.height - margin
273
+ });
224
274
  }
225
275
 
226
276
  function createTray() {
@@ -348,6 +398,22 @@ function handleTrayClick(item) {
348
398
  }
349
399
  }
350
400
 
401
+ // The overlay window is positioned once at creation; without these it would never
402
+ // react to the display layout changing under it. Re-home on any display add/remove/
403
+ // metrics change, and on resume from sleep (which commonly fires a metrics change
404
+ // and, on Windows, can blank a transparent surface — re-asserting bounds repaints).
405
+ function registerDisplayWatchers() {
406
+ const onDisplayChange = () => rehomeOverlay({ persist: false });
407
+ screen.on("display-metrics-changed", onDisplayChange);
408
+ screen.on("display-added", onDisplayChange);
409
+ screen.on("display-removed", onDisplayChange);
410
+ try {
411
+ powerMonitor.on("resume", onDisplayChange);
412
+ } catch {
413
+ // powerMonitor is unavailable on some platforms/headless; never fatal.
414
+ }
415
+ }
416
+
351
417
  function registerRendererHandlers() {
352
418
  ipcMain.handle("haya-pet:list-sessions", () => buildSessionPayload());
353
419
 
@@ -441,24 +507,27 @@ function togglePet() {
441
507
  if (!petWindow) {
442
508
  return;
443
509
  }
444
- petWindow.isVisible() ? petWindow.hide() : petWindow.show();
510
+ if (petWindow.isVisible()) {
511
+ petWindow.hide();
512
+ } else {
513
+ // Re-home on show: a stranded (off-screen) window still reports isVisible()
514
+ // === true, so the user's first click hides it and this second click must put
515
+ // it back on a valid display, not just show it at the same bad bounds.
516
+ rehomeOverlay();
517
+ }
445
518
  refreshTrayMenu();
446
519
  }
447
520
 
448
521
  function focusPet() {
449
- petWindow?.show();
522
+ // Relaunching `haya-pet` (second-instance) is also a "bring it back" gesture, so
523
+ // re-home rather than show at possibly-stale bounds.
524
+ rehomeOverlay();
450
525
  }
451
526
 
452
527
  function resetPosition() {
453
- // Drop the pet back to the bottom-right corner of its work area.
454
- const margin = 24;
455
- const size = scaledPetSize();
456
- petLocal = clampPetLocal({
457
- x: (currentWorkArea?.width ?? size.width) - size.width - margin,
458
- y: (currentWorkArea?.height ?? size.height) - size.height - margin
459
- });
460
- sendPetPosition();
461
- persistPetPosition();
528
+ // Re-home onto a valid display FIRST (a stale display layout is exactly when
529
+ // users reach for Reset), then drop the pet to the bottom-right corner.
530
+ rehomeOverlay({ recenter: true });
462
531
  }
463
532
 
464
533
  function setDisplayMode(displayMode) {
@@ -2,13 +2,6 @@
2
2
  // converts these descriptors into a native Menu; keeping it pure makes the
3
3
  // recovery controls testable.
4
4
 
5
- const DISPLAY_MODES = Object.freeze([
6
- { value: "global", label: "Global" },
7
- { value: "cluster", label: "Cluster" },
8
- { value: "per-terminal", label: "Per Terminal" },
9
- { value: "hybrid", label: "Hybrid" }
10
- ]);
11
-
12
5
  export function buildTrayTooltip() {
13
6
  return "HAYA Pet";
14
7
  }
@@ -22,17 +15,6 @@ export function buildTrayMenu(state = {}) {
22
15
  id: "toggle_pet",
23
16
  label: state.petVisible ? "Hide Pet" : "Show Pet"
24
17
  },
25
- {
26
- id: "display_mode",
27
- label: "Display Mode",
28
- submenu: DISPLAY_MODES.map((mode) => ({
29
- id: `display_mode:${mode.value}`,
30
- label: mode.label,
31
- value: mode.value,
32
- type: "radio",
33
- checked: state.displayMode === mode.value
34
- }))
35
- },
36
18
  {
37
19
  id: "sessions",
38
20
  label: "Active Sessions",
@@ -43,17 +25,9 @@ export function buildTrayMenu(state = {}) {
43
25
  label: "Installed Pets",
44
26
  submenu: buildPetItems(pets, state.selectedPetId)
45
27
  },
46
- {
47
- id: "attach_bubbles",
48
- label: "Attach Bubbles to Terminals",
49
- type: "checkbox",
50
- checked: Boolean(state.attachBubblesToTerminals)
51
- },
52
28
  { id: "reset_position", label: "Reset Position" },
53
- // Parked until a real settings window exists: every current setting already
54
- // has a home (tray toggles, `haya-pet hooks`, drag/grip gestures), so the
55
- // item would be a dead button. Re-enable once settings outgrow the tray
56
- // (e.g. bubble text size, linger duration) and a handler is wired up.
29
+ // Parked until a real settings window exists; partially implemented knobs
30
+ // stay hidden instead of showing dead or state-only controls.
57
31
  // { id: "settings", label: "Open Settings" },
58
32
  // Only present when the daily npm update check found a newer version;
59
33
  // clicking it opens the package page (the app never runs npm itself).
@@ -1,6 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "../../../test/harness.mjs";
3
- import { clampWindowBounds, resolveSavedPosition } from "../src/main/display-manager.js";
3
+ import {
4
+ clampLocalToWorkArea,
5
+ clampWindowBounds,
6
+ resolveOverlayPlacement,
7
+ resolveSavedPosition
8
+ } from "../src/main/display-manager.js";
4
9
 
5
10
  const displays = [
6
11
  {
@@ -46,3 +51,48 @@ test("uses primary display when no saved position exists", () => {
46
51
  { x: 416, y: 144, width: 384, height: 416, displayId: "primary", scaleFactor: 1.25 }
47
52
  );
48
53
  });
54
+
55
+ test("resolveOverlayPlacement spans the saved display and places the sprite inside it", () => {
56
+ assert.deepEqual(
57
+ resolveOverlayPlacement({
58
+ savedPosition: { x: 900, y: 120, width: 192, height: 208, displayId: "secondary" },
59
+ displays,
60
+ petSize: { width: 192, height: 208 }
61
+ }),
62
+ {
63
+ workArea: { x: 820, y: 20, width: 1160, height: 840 },
64
+ displayId: "secondary",
65
+ petLocal: { x: 80, y: 100 } // 900-820, 120-20
66
+ }
67
+ );
68
+ });
69
+
70
+ test("resolveOverlayPlacement re-homes a stranded position onto a valid display", () => {
71
+ // The pet was last on a display that is now gone (monitor unplugged). It must
72
+ // come back on the primary display, fully inside its work area — this is the
73
+ // recovery the disappear-and-can't-restore bug needed.
74
+ assert.deepEqual(
75
+ resolveOverlayPlacement({
76
+ savedPosition: { x: 4000, y: 4000, width: 192, height: 208, displayId: "gone" },
77
+ displays,
78
+ petSize: { width: 192, height: 208 }
79
+ }),
80
+ {
81
+ workArea: { x: 0, y: 0, width: 800, height: 560 },
82
+ displayId: "primary",
83
+ petLocal: { x: 608, y: 352 } // clamped to primary work area (800-192, 560-208)
84
+ }
85
+ );
86
+ });
87
+
88
+ test("clampLocalToWorkArea keeps the sprite within the work area for its size", () => {
89
+ const workArea = { x: 0, y: 0, width: 800, height: 560 };
90
+ assert.deepEqual(
91
+ clampLocalToWorkArea({ x: 5000, y: 5000 }, workArea, { width: 192, height: 208 }),
92
+ { x: 608, y: 352 }
93
+ );
94
+ assert.deepEqual(
95
+ clampLocalToWorkArea({ x: -50, y: -50 }, workArea, { width: 192, height: 208 }),
96
+ { x: 0, y: 0 }
97
+ );
98
+ });
@@ -13,9 +13,11 @@ const baseState = {
13
13
  test("includes the documented recovery controls", () => {
14
14
  const menu = buildTrayMenu(baseState);
15
15
  const ids = menu.map((item) => item.id);
16
- for (const id of ["toggle_pet", "display_mode", "sessions", "pets", "attach_bubbles", "reset_position", "quit"]) {
16
+ for (const id of ["toggle_pet", "sessions", "pets", "reset_position", "quit"]) {
17
17
  assert.ok(ids.includes(id), `missing ${id}`);
18
18
  }
19
+ assert.ok(!ids.includes("display_mode"), "display mode should stay hidden until implemented");
20
+ assert.ok(!ids.includes("attach_bubbles"), "attach bubbles should stay hidden until implemented");
19
21
  // "Open Settings" is parked until a settings window exists (every current
20
22
  // setting already has a tray/CLI/gesture home) — it must not be shown dead.
21
23
  assert.ok(!ids.includes("settings"), "settings item should stay hidden until implemented");
@@ -26,14 +28,6 @@ test("toggles the pet label based on visibility", () => {
26
28
  assert.equal(buildTrayMenu({ ...baseState, petVisible: false }).find((i) => i.id === "toggle_pet").label, "Show Pet");
27
29
  });
28
30
 
29
- test("checks the current display mode in the submenu", () => {
30
- const submenu = buildTrayMenu(baseState).find((i) => i.id === "display_mode").submenu;
31
- const hybrid = submenu.find((i) => i.value === "hybrid");
32
- const global = submenu.find((i) => i.value === "global");
33
- assert.equal(hybrid.checked, true);
34
- assert.equal(global.checked, false);
35
- });
36
-
37
31
  test("lists active sessions or shows an empty placeholder", () => {
38
32
  const withSessions = buildTrayMenu(baseState).find((i) => i.id === "sessions").submenu;
39
33
  assert.equal(withSessions[0].label, "Codex · netdisk-server");
@@ -42,11 +36,6 @@ test("lists active sessions or shows an empty placeholder", () => {
42
36
  assert.equal(empty[0].enabled, false);
43
37
  });
44
38
 
45
- test("reflects the attach-bubbles checkbox state", () => {
46
- assert.equal(buildTrayMenu(baseState).find((i) => i.id === "attach_bubbles").checked, true);
47
- assert.equal(buildTrayMenu({ ...baseState, attachBubblesToTerminals: false }).find((i) => i.id === "attach_bubbles").checked, false);
48
- });
49
-
50
39
  test("shows the update item only when a newer version is known", () => {
51
40
  const withoutUpdate = buildTrayMenu(baseState);
52
41
  assert.ok(!withoutUpdate.some((i) => i.id === "update"), "no update item by default");