@hayasaka7/haya-pet 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +34 -0
- package/.github/workflows/release.yml +61 -0
- package/LICENSE +21 -0
- package/README.md +247 -0
- package/apps/cli/src/haya-pet.js +395 -0
- package/apps/cli/test/haya-pet.test.mjs +339 -0
- package/apps/companion/README.md +83 -0
- package/apps/companion/package.json +17 -0
- package/apps/companion/src/main/display-manager.js +71 -0
- package/apps/companion/src/main/index.js +349 -0
- package/apps/companion/src/main/lock-file.js +52 -0
- package/apps/companion/src/main/panel-placement.js +45 -0
- package/apps/companion/src/main/pet-loader.js +2 -0
- package/apps/companion/src/main/position-store.js +3 -0
- package/apps/companion/src/main/preload.cjs +13 -0
- package/apps/companion/src/main/state-file.js +2 -0
- package/apps/companion/src/main/terminal-helper-client.js +79 -0
- package/apps/companion/src/main/terminal-locator.js +44 -0
- package/apps/companion/src/main/tray-menu.js +79 -0
- package/apps/companion/src/main/window-options.js +66 -0
- package/apps/companion/src/renderer/index.html +18 -0
- package/apps/companion/src/renderer/interaction-controller.js +114 -0
- package/apps/companion/src/renderer/pet-window.js +275 -0
- package/apps/companion/src/renderer/session-bubbles.js +138 -0
- package/apps/companion/src/renderer/styles.css +225 -0
- package/apps/companion/src/renderer/task-talk-window.js +141 -0
- package/apps/companion/test/display-manager.test.mjs +48 -0
- package/apps/companion/test/interaction-controller.test.mjs +107 -0
- package/apps/companion/test/panel-placement.test.mjs +60 -0
- package/apps/companion/test/position-store.test.mjs +54 -0
- package/apps/companion/test/state-file.test.mjs +52 -0
- package/apps/companion/test/terminal-helper-client.test.mjs +68 -0
- package/apps/companion/test/terminal-locator.test.mjs +35 -0
- package/apps/companion/test/tray-menu.test.mjs +45 -0
- package/apps/companion/test/window-options.test.mjs +62 -0
- package/apps/pet-preview/index.html +42 -0
- package/apps/pet-preview/src/preview-app.js +123 -0
- package/apps/pet-preview/src/preview-state.js +70 -0
- package/apps/pet-preview/src/preview.css +125 -0
- package/apps/pet-preview/test/preview-state.test.mjs +62 -0
- package/assets/fallback-pet/README.md +16 -0
- package/assets/fallback-pet/pet.json +13 -0
- package/docs/architecture.md +144 -0
- package/docs/known-issues.md +49 -0
- package/docs/publishing.md +48 -0
- package/docs/screenshots/README.md +7 -0
- package/docs/screenshots/folder-collapsed.png +0 -0
- package/docs/screenshots/hero.png +0 -0
- package/docs/screenshots/pet-overlay.png +0 -0
- package/docs/screenshots/session-bubbles.png +0 -0
- package/docs/screenshots/tray-menu.png +0 -0
- package/docs/troubleshooting.md +36 -0
- package/native/README.md +80 -0
- package/native/linux-window-helper/README.md +29 -0
- package/native/mac-window-helper/README.md +30 -0
- package/native/win-window-helper/Program.cs +312 -0
- package/native/win-window-helper/README.md +53 -0
- package/native/win-window-helper/win-window-helper.csproj +12 -0
- package/package.json +35 -0
- package/packages/adapters/src/adapter-info.js +61 -0
- package/packages/adapters/src/capabilities.js +39 -0
- package/packages/adapters/src/heuristics.js +114 -0
- package/packages/adapters/src/output-observer.js +164 -0
- package/packages/adapters/src/routing.js +86 -0
- package/packages/adapters/test/adapter-info.test.mjs +35 -0
- package/packages/adapters/test/capabilities.test.mjs +44 -0
- package/packages/adapters/test/heuristics.test.mjs +42 -0
- package/packages/adapters/test/output-observer.test.mjs +142 -0
- package/packages/adapters/test/routing.test.mjs +93 -0
- package/packages/app-state/src/state-file.js +53 -0
- package/packages/app-state/src/state.js +80 -0
- package/packages/app-state/test/state.test.mjs +36 -0
- package/packages/cli-core/src/companion-launcher.js +69 -0
- package/packages/cli-core/src/pty-runner.js +96 -0
- package/packages/cli-core/src/run-command.js +353 -0
- package/packages/cli-core/src/strip-ansi.js +16 -0
- package/packages/cli-core/test/companion-launcher.test.mjs +98 -0
- package/packages/cli-core/test/run-command.test.mjs +177 -0
- package/packages/cli-core/test/strip-ansi.test.mjs +27 -0
- package/packages/daemon-core/src/daemon-runtime.js +49 -0
- package/packages/daemon-core/src/ipc-server.js +180 -0
- package/packages/daemon-core/src/ipc-transport.js +70 -0
- package/packages/daemon-core/src/singleton.js +46 -0
- package/packages/daemon-core/test/daemon-runtime.test.mjs +65 -0
- package/packages/daemon-core/test/ipc-server.test.mjs +70 -0
- package/packages/daemon-core/test/ipc-transport.test.mjs +72 -0
- package/packages/daemon-core/test/singleton.test.mjs +32 -0
- package/packages/pet-core/src/animation-state.js +84 -0
- package/packages/pet-core/src/animator.js +26 -0
- package/packages/pet-core/src/atlas.js +81 -0
- package/packages/pet-core/src/discovery.js +90 -0
- package/packages/pet-core/src/manifest.js +112 -0
- package/packages/pet-core/src/validation.js +43 -0
- package/packages/pet-core/test/animation-state.test.mjs +47 -0
- package/packages/pet-core/test/animator.test.mjs +31 -0
- package/packages/pet-core/test/atlas.test.mjs +81 -0
- package/packages/pet-core/test/discovery.test.mjs +93 -0
- package/packages/pet-core/test/manifest.test.mjs +93 -0
- package/packages/pet-core/test/validation.test.mjs +69 -0
- package/packages/platform-core/src/capabilities.js +49 -0
- package/packages/platform-core/src/paths.js +75 -0
- package/packages/platform-core/src/platform.js +15 -0
- package/packages/platform-core/test/platform.test.mjs +84 -0
- package/packages/protocol/src/messages.js +156 -0
- package/packages/protocol/test/messages.test.mjs +112 -0
- package/packages/session-core/src/bubble-linger.js +47 -0
- package/packages/session-core/src/bubble-view.js +79 -0
- package/packages/session-core/src/pet-state.js +56 -0
- package/packages/session-core/src/priority.js +56 -0
- package/packages/session-core/src/registry.js +144 -0
- package/packages/session-core/src/summaries.js +54 -0
- package/packages/session-core/test/bubble-linger.test.mjs +96 -0
- package/packages/session-core/test/bubble-view.test.mjs +79 -0
- package/packages/session-core/test/pet-state.test.mjs +118 -0
- package/packages/session-core/test/priority.test.mjs +53 -0
- package/packages/session-core/test/registry.test.mjs +161 -0
- package/packages/session-core/test/summaries.test.mjs +38 -0
- package/packages/task-core/src/approvals.js +91 -0
- package/packages/task-core/src/controls.js +61 -0
- package/packages/task-core/src/replies.js +80 -0
- package/packages/task-core/src/task-events.js +101 -0
- package/packages/task-core/src/task-status.js +93 -0
- package/packages/task-core/src/task-store.js +74 -0
- package/packages/task-core/test/approvals.test.mjs +61 -0
- package/packages/task-core/test/controls.test.mjs +61 -0
- package/packages/task-core/test/replies.test.mjs +51 -0
- package/packages/task-core/test/task-events.test.mjs +67 -0
- package/packages/task-core/test/task-status.test.mjs +49 -0
- package/packages/task-core/test/task-store.test.mjs +65 -0
- package/test/harness.mjs +22 -0
- package/test/run-tests.mjs +47 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The pet sprite cell is 192×208. The overlay window itself spans the whole
|
|
2
|
+
// work area (so the bubble panel can sit on whichever side of the pet has room),
|
|
3
|
+
// and is kept click-through except over the pet + bubbles.
|
|
4
|
+
export const PET_SIZE = Object.freeze({
|
|
5
|
+
width: 192,
|
|
6
|
+
height: 208
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const DEFAULT_BOUNDS = PET_SIZE;
|
|
10
|
+
|
|
11
|
+
export function buildPetWindowOptions({ capabilities, bounds = DEFAULT_BOUNDS } = {}) {
|
|
12
|
+
const overlaySupported = capabilities?.transparentOverlay === "required";
|
|
13
|
+
const overlayMode = overlaySupported ? "transparent-overlay" : "fallback-window";
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
overlayMode,
|
|
17
|
+
browserWindow: overlaySupported
|
|
18
|
+
? buildTransparentOverlayOptions(bounds)
|
|
19
|
+
: buildFallbackWindowOptions(bounds)
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildTransparentOverlayOptions(bounds) {
|
|
24
|
+
return {
|
|
25
|
+
...normalizeBounds(bounds),
|
|
26
|
+
transparent: true,
|
|
27
|
+
frame: false,
|
|
28
|
+
alwaysOnTop: true,
|
|
29
|
+
skipTaskbar: true,
|
|
30
|
+
resizable: false,
|
|
31
|
+
hasShadow: false,
|
|
32
|
+
focusable: false,
|
|
33
|
+
backgroundColor: "#00000000"
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildFallbackWindowOptions(bounds) {
|
|
38
|
+
return {
|
|
39
|
+
...normalizeBounds(bounds),
|
|
40
|
+
transparent: false,
|
|
41
|
+
frame: true,
|
|
42
|
+
alwaysOnTop: true,
|
|
43
|
+
skipTaskbar: false,
|
|
44
|
+
resizable: true,
|
|
45
|
+
hasShadow: true,
|
|
46
|
+
focusable: true,
|
|
47
|
+
backgroundColor: "#ffffff"
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeBounds(bounds) {
|
|
52
|
+
const normalized = {
|
|
53
|
+
width: bounds.width ?? DEFAULT_BOUNDS.width,
|
|
54
|
+
height: bounds.height ?? DEFAULT_BOUNDS.height
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
if (Number.isFinite(bounds.x)) {
|
|
58
|
+
normalized.x = bounds.x;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (Number.isFinite(bounds.y)) {
|
|
62
|
+
normalized.y = bounds.y;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return normalized;
|
|
66
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self' file:; img-src 'self' file: data:; style-src 'self' 'unsafe-inline';" />
|
|
6
|
+
<title>Haya Pet</title>
|
|
7
|
+
<link rel="stylesheet" href="styles.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<!-- Layer 1: the global pet overlay. -->
|
|
11
|
+
<canvas id="pet-canvas" class="interactive" width="192" height="208"></canvas>
|
|
12
|
+
|
|
13
|
+
<!-- Layer 2: ongoing-session progress bubbles + folder toggle. -->
|
|
14
|
+
<div id="bubbles" class="bubbles"></div>
|
|
15
|
+
|
|
16
|
+
<script type="module" src="pet-window.js"></script>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const DEFAULT_DRAG_THRESHOLD = 6;
|
|
2
|
+
const DEFAULT_CLICK_MAX_DURATION = 300;
|
|
3
|
+
const DEFAULT_DOUBLE_CLICK_WINDOW = 350;
|
|
4
|
+
|
|
5
|
+
// Distinguishes click / double-click / drag from raw pointer events.
|
|
6
|
+
//
|
|
7
|
+
// - Drag is reported synchronously from pointerMove/pointerUp so window movement
|
|
8
|
+
// stays responsive, and its direction is derived from the *incremental*
|
|
9
|
+
// movement (last point -> current point) so left/right flips the instant the
|
|
10
|
+
// user reverses, instead of waiting to cancel out the cumulative offset.
|
|
11
|
+
// - Click is *deferred* by the double-click window and delivered via onAction;
|
|
12
|
+
// if a second click lands first, the pending single click is cancelled so a
|
|
13
|
+
// double click never also fires a wave. Timers are injectable for testing.
|
|
14
|
+
export function createInteractionController(options = {}) {
|
|
15
|
+
const dragThreshold = options.dragThreshold ?? DEFAULT_DRAG_THRESHOLD;
|
|
16
|
+
const clickMaxDuration = options.clickMaxDuration ?? DEFAULT_CLICK_MAX_DURATION;
|
|
17
|
+
const doubleClickWindow = options.doubleClickWindow ?? DEFAULT_DOUBLE_CLICK_WINDOW;
|
|
18
|
+
const onAction = options.onAction ?? (() => {});
|
|
19
|
+
const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
20
|
+
const clearTimer = options.clearTimer ?? ((id) => clearTimeout(id));
|
|
21
|
+
|
|
22
|
+
let pointerStart;
|
|
23
|
+
let lastPoint;
|
|
24
|
+
let dragging = false;
|
|
25
|
+
let lastDirection;
|
|
26
|
+
let pendingClickTimer;
|
|
27
|
+
|
|
28
|
+
function cancelPendingClick() {
|
|
29
|
+
if (pendingClickTimer !== undefined) {
|
|
30
|
+
clearTimer(pendingClickTimer);
|
|
31
|
+
pendingClickTimer = undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
pointerDown(point) {
|
|
37
|
+
pointerStart = point;
|
|
38
|
+
lastPoint = point;
|
|
39
|
+
dragging = false;
|
|
40
|
+
lastDirection = undefined;
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
pointerMove(point) {
|
|
44
|
+
if (!pointerStart) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const totalDx = point.x - pointerStart.x;
|
|
49
|
+
const totalDy = point.y - pointerStart.y;
|
|
50
|
+
|
|
51
|
+
if (!dragging && distance(totalDx, totalDy) <= dragThreshold) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
dragging = true;
|
|
56
|
+
|
|
57
|
+
const incrementalDx = point.x - lastPoint.x;
|
|
58
|
+
lastPoint = point;
|
|
59
|
+
|
|
60
|
+
let direction = lastDirection;
|
|
61
|
+
if (incrementalDx < 0) {
|
|
62
|
+
direction = "left";
|
|
63
|
+
} else if (incrementalDx > 0) {
|
|
64
|
+
direction = "right";
|
|
65
|
+
} else if (!direction) {
|
|
66
|
+
direction = totalDx < 0 ? "left" : "right";
|
|
67
|
+
}
|
|
68
|
+
lastDirection = direction;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
type: "drag",
|
|
72
|
+
direction,
|
|
73
|
+
action: direction === "left" ? "running-left" : "running-right"
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
pointerUp(point) {
|
|
78
|
+
if (!pointerStart) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const startedAt = pointerStart;
|
|
83
|
+
pointerStart = undefined;
|
|
84
|
+
|
|
85
|
+
if (dragging) {
|
|
86
|
+
dragging = false;
|
|
87
|
+
return { type: "drag-end" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const duration = point.time - startedAt.time;
|
|
91
|
+
if (duration > clickMaxDuration) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (pendingClickTimer !== undefined) {
|
|
96
|
+
// Second click within the window -> double click; drop the pending single.
|
|
97
|
+
cancelPendingClick();
|
|
98
|
+
onAction({ type: "double-click", action: "jumping" });
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
pendingClickTimer = setTimer(() => {
|
|
103
|
+
pendingClickTimer = undefined;
|
|
104
|
+
onAction({ type: "click", action: "waving" });
|
|
105
|
+
}, doubleClickWindow);
|
|
106
|
+
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function distance(x, y) {
|
|
113
|
+
return Math.sqrt(x * x + y * y);
|
|
114
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// Layer 1 renderer: the global pet overlay. The animation, interaction, and
|
|
2
|
+
// state-mapping logic all come from the unit-tested pure packages; this module
|
|
3
|
+
// is the browser glue that draws frames and forwards pointer gestures.
|
|
4
|
+
|
|
5
|
+
import { CELL_HEIGHT, CELL_WIDTH, getFrameRect } from "../../../../packages/pet-core/src/atlas.js";
|
|
6
|
+
import { getActionDurationMs, getFrameAt } from "../../../../packages/pet-core/src/animator.js";
|
|
7
|
+
import {
|
|
8
|
+
clearDragAction,
|
|
9
|
+
createAnimationState,
|
|
10
|
+
resolveCurrentAction,
|
|
11
|
+
setDragAction,
|
|
12
|
+
setStableAction,
|
|
13
|
+
triggerOneShot
|
|
14
|
+
} from "../../../../packages/pet-core/src/animation-state.js";
|
|
15
|
+
import { resolveCompanionPetState } from "../../../../packages/session-core/src/pet-state.js";
|
|
16
|
+
import { resolveVisibleBubbles } from "../../../../packages/session-core/src/bubble-linger.js";
|
|
17
|
+
import { resolvePanelPlacement } from "../main/panel-placement.js";
|
|
18
|
+
import { createInteractionController } from "./interaction-controller.js";
|
|
19
|
+
import { createBubbleList } from "./session-bubbles.js";
|
|
20
|
+
|
|
21
|
+
const bridge = window.aiPet;
|
|
22
|
+
const canvas = document.getElementById("pet-canvas");
|
|
23
|
+
const ctx = canvas.getContext("2d");
|
|
24
|
+
const panelEl = document.getElementById("bubbles");
|
|
25
|
+
|
|
26
|
+
const controller = createInteractionController({
|
|
27
|
+
// Click is deferred so a double-click never also fires a wave. Clicking the
|
|
28
|
+
// pet folds/unfolds the session bubbles; double-click forces them open.
|
|
29
|
+
onAction: (event) => {
|
|
30
|
+
if (event.type === "click") {
|
|
31
|
+
playOneShot("waving");
|
|
32
|
+
bubbleList.toggle();
|
|
33
|
+
} else if (event.type === "double-click") {
|
|
34
|
+
playOneShot("jumping");
|
|
35
|
+
bubbleList.expand();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
// Reposition the panel beside the pet after every (re)render, since its size
|
|
40
|
+
// changes with the number of bubbles and the collapsed/expanded state.
|
|
41
|
+
const bubbleList = createBubbleList(panelEl, { onRender: placePanel });
|
|
42
|
+
|
|
43
|
+
let animationState = createAnimationState("idle");
|
|
44
|
+
let manifest = { frameDurationMs: 120 };
|
|
45
|
+
let spritesheet;
|
|
46
|
+
let currentAction = "idle";
|
|
47
|
+
let actionStart = 0;
|
|
48
|
+
let dragOffset = { x: 0, y: 0 };
|
|
49
|
+
let previousSessionStates = {};
|
|
50
|
+
// The pet lives at this work-area-relative position inside the full-screen
|
|
51
|
+
// overlay window; dragging moves it via CSS (the window never moves).
|
|
52
|
+
let petLocal = { x: 0, y: 0 };
|
|
53
|
+
// Linger bookkeeping so a finished session's bubble stays ~2s before vanishing.
|
|
54
|
+
let lingerState = {};
|
|
55
|
+
let lingerTimer;
|
|
56
|
+
let lastBubblesPayload = [];
|
|
57
|
+
|
|
58
|
+
function setupPet(config) {
|
|
59
|
+
if (config?.pet?.manifest) {
|
|
60
|
+
manifest = config.pet.manifest;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (config?.petPosition) {
|
|
64
|
+
applyPetPosition(config.petPosition);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (config?.pet?.spritesheetUrl) {
|
|
68
|
+
const image = new Image();
|
|
69
|
+
image.onload = () => {
|
|
70
|
+
spritesheet = image;
|
|
71
|
+
};
|
|
72
|
+
image.src = config.pet.spritesheetUrl;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function applyPetPosition(pos) {
|
|
77
|
+
petLocal = clampPetLocal(pos);
|
|
78
|
+
canvas.style.left = `${petLocal.x}px`;
|
|
79
|
+
canvas.style.top = `${petLocal.y}px`;
|
|
80
|
+
placePanel();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function clampPetLocal(pos) {
|
|
84
|
+
const maxX = Math.max(0, window.innerWidth - canvas.width);
|
|
85
|
+
const maxY = Math.max(0, window.innerHeight - canvas.height);
|
|
86
|
+
return {
|
|
87
|
+
x: Math.min(Math.max(Math.round(pos?.x ?? 0), 0), maxX),
|
|
88
|
+
y: Math.min(Math.max(Math.round(pos?.y ?? 0), 0), maxY)
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Anchors the folder button on whichever side of the pet has room, fully inside
|
|
93
|
+
// the overlay (== the work area), so dragging never pushes it off-screen. The
|
|
94
|
+
// button's box drives the placement; the (absolutely positioned) list then
|
|
95
|
+
// opens toward the screen centre, so toggling it never moves the button.
|
|
96
|
+
function placePanel() {
|
|
97
|
+
const rect = panelEl.getBoundingClientRect();
|
|
98
|
+
if (!rect.width || !rect.height) {
|
|
99
|
+
return; // nothing to place (no active sessions)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const workArea = { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight };
|
|
103
|
+
const placement = resolvePanelPlacement({
|
|
104
|
+
pet: { x: petLocal.x, y: petLocal.y, width: canvas.width, height: canvas.height },
|
|
105
|
+
panel: { width: rect.width, height: rect.height },
|
|
106
|
+
workArea
|
|
107
|
+
});
|
|
108
|
+
panelEl.style.left = `${Math.round(placement.x)}px`;
|
|
109
|
+
panelEl.style.top = `${Math.round(placement.y)}px`;
|
|
110
|
+
|
|
111
|
+
const list = panelEl.querySelector(".bubble-list");
|
|
112
|
+
if (list) {
|
|
113
|
+
const margin = 12;
|
|
114
|
+
const openUp = placement.y > workArea.height / 2;
|
|
115
|
+
const alignRight = placement.x + rect.width / 2 > workArea.width / 2;
|
|
116
|
+
list.dataset.openDirection = openUp ? "up" : "down";
|
|
117
|
+
list.dataset.openAlign = alignRight ? "right" : "left";
|
|
118
|
+
// Cap the height to the room actually available on the chosen side.
|
|
119
|
+
const room = openUp ? placement.y - margin : workArea.height - (placement.y + rect.height) - margin;
|
|
120
|
+
list.style.maxHeight = `${Math.max(96, Math.round(room))}px`;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function frameLoop(now) {
|
|
125
|
+
const action = resolveCurrentAction(animationState, now);
|
|
126
|
+
if (action !== currentAction) {
|
|
127
|
+
currentAction = action;
|
|
128
|
+
actionStart = now;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const frameIndex = getFrameAt(action, now - actionStart, manifest);
|
|
132
|
+
draw(action, frameIndex);
|
|
133
|
+
requestAnimationFrame(frameLoop);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function draw(action, frameIndex) {
|
|
137
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
138
|
+
|
|
139
|
+
if (spritesheet) {
|
|
140
|
+
const rect = getFrameRect(action, frameIndex);
|
|
141
|
+
ctx.drawImage(spritesheet, rect.x, rect.y, rect.width, rect.height, 0, 0, canvas.width, canvas.height);
|
|
142
|
+
} else {
|
|
143
|
+
drawPlaceholder(action, frameIndex);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Development fallback so the pet still renders without a Codex spritesheet.
|
|
148
|
+
function drawPlaceholder(action, frameIndex) {
|
|
149
|
+
ctx.fillStyle = "rgba(110, 168, 254, 0.85)";
|
|
150
|
+
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);
|
|
152
|
+
ctx.fill();
|
|
153
|
+
ctx.fillStyle = "#001233";
|
|
154
|
+
ctx.font = "14px system-ui";
|
|
155
|
+
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);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function playOneShot(action) {
|
|
161
|
+
animationState = triggerOneShot(animationState, action, performance.now(), getActionDurationMs(action, manifest));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- Pointer interaction (click vs drag distinction lives in the controller) ---
|
|
165
|
+
|
|
166
|
+
canvas.addEventListener("pointerdown", (event) => {
|
|
167
|
+
canvas.setPointerCapture(event.pointerId);
|
|
168
|
+
dragOffset = { x: event.offsetX, y: event.offsetY };
|
|
169
|
+
// Window-local (clientX/Y) coords throughout — the overlay covers the work area.
|
|
170
|
+
controller.pointerDown({ x: event.clientX, y: event.clientY, time: performance.now() });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
canvas.addEventListener("pointermove", (event) => {
|
|
174
|
+
const result = controller.pointerMove({ x: event.clientX, y: event.clientY, time: performance.now() });
|
|
175
|
+
if (result?.type === "drag") {
|
|
176
|
+
animationState = setDragAction(animationState, result.direction);
|
|
177
|
+
applyPetPosition({ x: event.clientX - dragOffset.x, y: event.clientY - dragOffset.y });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
canvas.addEventListener("pointerup", (event) => {
|
|
182
|
+
// Click / double-click are delivered asynchronously via onAction; only the
|
|
183
|
+
// synchronous drag-end is handled here.
|
|
184
|
+
const result = controller.pointerUp({ x: event.clientX, y: event.clientY, time: performance.now() });
|
|
185
|
+
if (result?.type === "drag-end") {
|
|
186
|
+
animationState = clearDragAction(animationState);
|
|
187
|
+
bridge?.savePetPosition?.(petLocal);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Re-clamp and re-place when the work area changes (display/resolution change).
|
|
192
|
+
window.addEventListener("resize", () => {
|
|
193
|
+
applyPetPosition(petLocal);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// --- Session wiring ---
|
|
197
|
+
|
|
198
|
+
function applySessions(payload) {
|
|
199
|
+
const allBubbles = payload?.bubbles ?? [];
|
|
200
|
+
const { stableAction, oneShots, nextStates } = resolveCompanionPetState({
|
|
201
|
+
bubbles: allBubbles,
|
|
202
|
+
prioritySessionId: payload?.prioritySessionId,
|
|
203
|
+
previousStates: previousSessionStates
|
|
204
|
+
});
|
|
205
|
+
previousSessionStates = nextStates;
|
|
206
|
+
|
|
207
|
+
// The pet's body language is driven only by active work (handled inside the
|
|
208
|
+
// resolver); the panel shows every live session via renderBubbles().
|
|
209
|
+
lastBubblesPayload = allBubbles;
|
|
210
|
+
renderBubbles();
|
|
211
|
+
|
|
212
|
+
for (const action of oneShots) {
|
|
213
|
+
playOneShot(action);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
animationState = setStableAction(animationState, stableAction);
|
|
217
|
+
refreshMouseIgnore(lastPointer.x, lastPointer.y);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Applies the 2s linger: finished sessions keep their final status icon briefly,
|
|
221
|
+
// then drop off. Re-runs itself when the linger window elapses so a bubble
|
|
222
|
+
// disappears on schedule even if no new session event arrives.
|
|
223
|
+
function renderBubbles() {
|
|
224
|
+
const { visible, lingerState: nextLinger, nextWakeMs } = resolveVisibleBubbles({
|
|
225
|
+
bubbles: lastBubblesPayload,
|
|
226
|
+
now: Date.now(),
|
|
227
|
+
lingerState
|
|
228
|
+
});
|
|
229
|
+
lingerState = nextLinger;
|
|
230
|
+
bubbleList.render(visible);
|
|
231
|
+
|
|
232
|
+
clearTimeout(lingerTimer);
|
|
233
|
+
if (nextWakeMs !== undefined) {
|
|
234
|
+
lingerTimer = setTimeout(renderBubbles, nextWakeMs);
|
|
235
|
+
}
|
|
236
|
+
refreshMouseIgnore(lastPointer.x, lastPointer.y);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --- Click-through forwarding ---
|
|
240
|
+
//
|
|
241
|
+
// The overlay window covers a large area but should only intercept the mouse
|
|
242
|
+
// over the pet and the bubble chips; everywhere else must pass through to the
|
|
243
|
+
// desktop. The window is created ignoring mouse events (with forwarding), and
|
|
244
|
+
// we flip it back on whenever the cursor is over an `.interactive` element.
|
|
245
|
+
|
|
246
|
+
let mouseIgnored;
|
|
247
|
+
let lastPointer = { x: -1, y: -1 };
|
|
248
|
+
|
|
249
|
+
function refreshMouseIgnore(x, y) {
|
|
250
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const target = document.elementFromPoint(x, y);
|
|
254
|
+
const interactive = Boolean(target && target.closest(".interactive"));
|
|
255
|
+
const ignore = !interactive;
|
|
256
|
+
if (ignore !== mouseIgnored) {
|
|
257
|
+
mouseIgnored = ignore;
|
|
258
|
+
bridge?.setMouseIgnore?.(ignore);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
window.addEventListener("mousemove", (event) => {
|
|
263
|
+
lastPointer = { x: event.clientX, y: event.clientY };
|
|
264
|
+
refreshMouseIgnore(event.clientX, event.clientY);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
if (bridge) {
|
|
268
|
+
bridge.setMouseIgnore?.(true);
|
|
269
|
+
bridge.onConfig(setupPet);
|
|
270
|
+
bridge.onSessions(applySessions);
|
|
271
|
+
bridge.onPetPosition?.(applyPetPosition);
|
|
272
|
+
bridge.listSessions().then(applySessions).catch(() => {});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
requestAnimationFrame(frameLoop);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Layer 2 renderer: Codex-style progress bubbles. Each ongoing AI session shows
|
|
2
|
+
// a status icon, its title (client · project), and the newest activity line.
|
|
3
|
+
// A folder button at the top toggles the whole stack open/closed.
|
|
4
|
+
//
|
|
5
|
+
// Consumes the bubble view models produced by packages/session-core/bubble-view.js
|
|
6
|
+
// (already shaped + sorted by the main process), so this module is pure DOM glue.
|
|
7
|
+
// Status kinds come from `bubble.statusKind`: working | done | attention | failed.
|
|
8
|
+
|
|
9
|
+
const STATUS_GLYPH = Object.freeze({
|
|
10
|
+
working: "", // animated spinner drawn via CSS
|
|
11
|
+
done: "✓",
|
|
12
|
+
attention: "!",
|
|
13
|
+
failed: "✕",
|
|
14
|
+
idle: ""
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export function createBubbleList(container, { collapsed = false, onRender } = {}) {
|
|
18
|
+
let lastBubbles = [];
|
|
19
|
+
let isCollapsed = collapsed;
|
|
20
|
+
|
|
21
|
+
function toggle() {
|
|
22
|
+
isCollapsed = !isCollapsed;
|
|
23
|
+
paint();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function paint() {
|
|
27
|
+
container.innerHTML = "";
|
|
28
|
+
|
|
29
|
+
// Nothing running -> keep the overlay clean (just the pet, no folder button).
|
|
30
|
+
if (lastBubbles.length > 0) {
|
|
31
|
+
container.appendChild(renderFolderButton(lastBubbles, isCollapsed, toggle));
|
|
32
|
+
|
|
33
|
+
if (!isCollapsed) {
|
|
34
|
+
const list = document.createElement("div");
|
|
35
|
+
list.className = "bubble-list";
|
|
36
|
+
for (const bubble of lastBubbles) {
|
|
37
|
+
list.appendChild(renderBubble(bubble));
|
|
38
|
+
}
|
|
39
|
+
container.appendChild(list);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Let the host reposition the panel now that its size is known.
|
|
44
|
+
onRender?.();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
render(bubbles = []) {
|
|
49
|
+
lastBubbles = Array.isArray(bubbles) ? bubbles : [];
|
|
50
|
+
paint();
|
|
51
|
+
},
|
|
52
|
+
expand() {
|
|
53
|
+
if (isCollapsed) {
|
|
54
|
+
toggle();
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
toggle,
|
|
58
|
+
isCollapsed: () => isCollapsed
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function renderFolderButton(bubbles, collapsed, onToggle) {
|
|
63
|
+
const btn = document.createElement("button");
|
|
64
|
+
// "interactive" marks the only pointer-active regions so the rest of the
|
|
65
|
+
// overlay window can stay click-through (see pet-window.js).
|
|
66
|
+
btn.className = "folder-toggle interactive";
|
|
67
|
+
btn.type = "button";
|
|
68
|
+
btn.setAttribute("aria-expanded", String(!collapsed));
|
|
69
|
+
btn.title = collapsed ? "Show sessions" : "Hide sessions";
|
|
70
|
+
|
|
71
|
+
// A simple disclosure caret (rotates when open) — quieter than a folder glyph.
|
|
72
|
+
const caret = document.createElement("span");
|
|
73
|
+
caret.className = "caret";
|
|
74
|
+
|
|
75
|
+
const count = document.createElement("span");
|
|
76
|
+
count.className = "folder-count";
|
|
77
|
+
count.textContent = String(bubbles.length);
|
|
78
|
+
|
|
79
|
+
// A small dot summary of the most urgent kind, so the user can tell something
|
|
80
|
+
// needs attention without opening the folder.
|
|
81
|
+
const summary = document.createElement("span");
|
|
82
|
+
summary.className = "folder-summary";
|
|
83
|
+
summary.dataset.kind = mostUrgentKind(bubbles);
|
|
84
|
+
|
|
85
|
+
btn.append(caret, count, summary);
|
|
86
|
+
btn.addEventListener("click", onToggle);
|
|
87
|
+
return btn;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function renderBubble(bubble) {
|
|
91
|
+
const el = document.createElement("div");
|
|
92
|
+
el.className = "bubble interactive";
|
|
93
|
+
el.dataset.sessionId = bubble.sessionId;
|
|
94
|
+
el.dataset.kind = bubble.statusKind;
|
|
95
|
+
|
|
96
|
+
const icon = document.createElement("span");
|
|
97
|
+
icon.className = "status-icon";
|
|
98
|
+
icon.dataset.kind = bubble.statusKind;
|
|
99
|
+
icon.textContent = STATUS_GLYPH[bubble.statusKind] ?? "";
|
|
100
|
+
icon.title = bubble.statusLabel;
|
|
101
|
+
|
|
102
|
+
const body = document.createElement("div");
|
|
103
|
+
body.className = "body";
|
|
104
|
+
|
|
105
|
+
const title = document.createElement("div");
|
|
106
|
+
title.className = "title";
|
|
107
|
+
title.innerHTML = `<span class="client">${escapeHtml(bubble.clientName)}</span> ` +
|
|
108
|
+
`<span class="project">${escapeHtml(bubble.projectName)}</span>`;
|
|
109
|
+
|
|
110
|
+
const activity = document.createElement("div");
|
|
111
|
+
activity.className = "activity";
|
|
112
|
+
activity.textContent = bubble.summary;
|
|
113
|
+
activity.title = `${bubble.statusLabel} · ${bubble.elapsedLabel}`;
|
|
114
|
+
|
|
115
|
+
body.append(title, activity);
|
|
116
|
+
el.append(icon, body);
|
|
117
|
+
return el;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Picks the kind that should win the collapsed-folder dot: a failure or a
|
|
121
|
+
// request for attention always beats ongoing work, which beats done/idle.
|
|
122
|
+
const KIND_RANK = Object.freeze({ failed: 0, attention: 1, working: 2, done: 3, idle: 4 });
|
|
123
|
+
function mostUrgentKind(bubbles) {
|
|
124
|
+
let best = "idle";
|
|
125
|
+
for (const bubble of bubbles) {
|
|
126
|
+
if ((KIND_RANK[bubble.statusKind] ?? 9) < (KIND_RANK[best] ?? 9)) {
|
|
127
|
+
best = bubble.statusKind;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return best;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function escapeHtml(value) {
|
|
134
|
+
return String(value ?? "")
|
|
135
|
+
.replace(/&/g, "&")
|
|
136
|
+
.replace(/</g, "<")
|
|
137
|
+
.replace(/>/g, ">");
|
|
138
|
+
}
|