@clowder-ai/companion 0.1.0-alpha.1 → 0.1.0-alpha.11

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.
Files changed (50) hide show
  1. package/NOTICE +10 -2
  2. package/README.md +48 -0
  3. package/manifest.json +5 -5
  4. package/package.json +17 -6
  5. package/plugin.yaml +5 -5
  6. package/renderer/client.mjs +6 -0
  7. package/renderer/companion-identity.mjs +41 -0
  8. package/renderer/conversation.mjs +34 -17
  9. package/renderer/decision-view.mjs +30 -0
  10. package/renderer/index.html +84 -26
  11. package/renderer/living-body.mjs +147 -0
  12. package/renderer/living-edge.mjs +16 -0
  13. package/renderer/living-geometry.mjs +96 -0
  14. package/renderer/motion-facts.mjs +79 -0
  15. package/renderer/native-work-motion.mjs +24 -0
  16. package/renderer/pet-controls.mjs +84 -0
  17. package/renderer/pet-motion.mjs +352 -0
  18. package/renderer/recent-bubble.mjs +39 -0
  19. package/renderer/skins/ragdoll-v1.png +0 -0
  20. package/renderer/skins/xianxian-codex.webp +0 -0
  21. package/renderer/skins/xianxian-living/clips/carry.webm +0 -0
  22. package/renderer/skins/xianxian-living/clips/peek.webm +0 -0
  23. package/renderer/skins/xianxian-living/clips/peek_fade.webm +0 -0
  24. package/renderer/skins/xianxian-living/clips/pounce.webm +0 -0
  25. package/renderer/skins/xianxian-living/clips/sleep.webm +0 -0
  26. package/renderer/skins/xianxian-living/clips/think.webm +0 -0
  27. package/renderer/skins/xianxian-living/clips/trot.webm +0 -0
  28. package/renderer/skins/xianxian-living/clips/wait.webm +0 -0
  29. package/renderer/skins/xianxian-living/clips/wake.webm +0 -0
  30. package/renderer/skins/xianxian-living/layers/body.webp +0 -0
  31. package/renderer/skins/xianxian-living/layers/closed.webp +0 -0
  32. package/renderer/skins/xianxian-living/layers/earL.webp +0 -0
  33. package/renderer/skins/xianxian-living/layers/earR.webp +0 -0
  34. package/renderer/skins/xianxian-living/layers/head.webp +0 -0
  35. package/renderer/skins/xianxian-living/layers/irisL.webp +0 -0
  36. package/renderer/skins/xianxian-living/layers/irisR.webp +0 -0
  37. package/renderer/skins/xianxian-living/layers/meta.json +88 -0
  38. package/renderer/skins/xianxian-living/layers/mouth.webp +0 -0
  39. package/renderer/skins/xianxian-living/layers/scared.webp +0 -0
  40. package/renderer/skins/xianxian-living/layers/tail.webp +0 -0
  41. package/renderer/skins/xianxian-living/layers/whiteL.webp +0 -0
  42. package/renderer/skins/xianxian-living/layers/whiteR.webp +0 -0
  43. package/renderer/skins/xianxian-sleeping-row.png +0 -0
  44. package/renderer/skins/xianxian-staged-thought-row.png +0 -0
  45. package/renderer/skins/xianxian-working-row.png +0 -0
  46. package/renderer/skins/yarn-ball.png +0 -0
  47. package/renderer/style.css +114 -308
  48. package/renderer/surface.mjs +198 -63
  49. package/renderer/transcript-view.mjs +42 -7
  50. package/source-lock.json +138 -4
@@ -0,0 +1,147 @@
1
+ import { isLivingClip, livingClipFor, placeLivingClip } from './living-geometry.mjs';
2
+ export { isLivingClip } from './living-geometry.mjs';
3
+ const setHidden = (node, hidden) => {
4
+ if (!node) return;
5
+ node.toggleAttribute?.('hidden', hidden);
6
+ node.hidden = hidden;
7
+ };
8
+
9
+ /** One visible body: a layered sit or one muted clip, with a bounded sleep/wake hand-off. */
10
+ export class LivingBody {
11
+ constructor({ root, sit, video, transitionVideo, setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout }) {
12
+ this.root = root;
13
+ this.sit = sit;
14
+ this.video = video;
15
+ this.standby = transitionVideo;
16
+ this.setTimer = (callback, delay) => setTimer.call(globalThis, callback, delay);
17
+ this.clearTimer = id => clearTimer.call(globalThis, id);
18
+ this.action = '';
19
+ this.revision = 0;
20
+ }
21
+
22
+ show(action, { reducedMotion = false, onEnded = () => {} } = {}) {
23
+ const dockEdge = this.root.dataset.dockEdge;
24
+ const dockZone = this.root.dataset.dockZone;
25
+ if (this.action === action && this.reducedMotion === reducedMotion && this.dockEdge === dockEdge && this.dockZone === dockZone) return;
26
+ const previous = this.action;
27
+ this.action = action;
28
+ this.reducedMotion = reducedMotion;
29
+ this.dockEdge = dockEdge;
30
+ this.dockZone = dockZone;
31
+ const revision = ++this.revision;
32
+ this.cancelFade();
33
+ this.root.dataset.pose = this.pose(action);
34
+ this.root.dataset.direction = action === 'running-left' || (action === 'peek' && dockEdge === 'left') ? 'left' : 'right';
35
+ const clip = !reducedMotion ? livingClipFor(action, dockZone) : null;
36
+ if (!clip) {
37
+ this.hideVideos();
38
+ setHidden(this.sit, false);
39
+ return;
40
+ }
41
+ const crossfade = previous === 'sleeping' && action === 'waking' && this.standby;
42
+ if (crossfade) this.crossfade(clip, revision, onEnded);
43
+ else this.showClip(this.video, clip, revision, onEnded);
44
+ }
45
+
46
+ pose(action) {
47
+ if (action === 'failed') return 'scared';
48
+ if (action === 'waiting') return 'listening';
49
+ if (['sleeping', 'pending_decision', 'held', 'working', 'fetching', 'studying'].includes(action))
50
+ return action === 'pending_decision' ? 'pending' : action;
51
+ return 'sit';
52
+ }
53
+
54
+ showClip(video, clip, revision, onEnded) {
55
+ this.retireVideo(this.standby);
56
+ this.configure(video, clip, revision, onEnded);
57
+ video.style.opacity = '1';
58
+ setHidden(this.sit, true);
59
+ setHidden(video, false);
60
+ this.play(video, revision, onEnded);
61
+ }
62
+
63
+ crossfade(clip, revision, onEnded) {
64
+ const outgoing = this.video;
65
+ const incoming = this.standby;
66
+ this.configure(incoming, clip, revision, onEnded);
67
+ incoming.style.transition = `opacity ${clip.crossfadeMs}ms linear`;
68
+ outgoing.style.transition = `opacity ${clip.crossfadeMs}ms linear`;
69
+ incoming.style.opacity = '0';
70
+ outgoing.style.opacity = '1';
71
+ setHidden(this.sit, true);
72
+ setHidden(outgoing, false);
73
+ setHidden(incoming, false);
74
+ this.play(incoming, revision, onEnded);
75
+ incoming.style.opacity = '1';
76
+ outgoing.style.opacity = '0';
77
+ this.video = incoming;
78
+ this.standby = outgoing;
79
+ this.fadeTimer = this.setTimer(() => {
80
+ this.fadeTimer = undefined;
81
+ if (revision !== this.revision) return;
82
+ this.retireVideo(outgoing);
83
+ }, clip.crossfadeMs);
84
+ }
85
+
86
+ configure(video, clip, revision, onEnded) {
87
+ video.onended = null;
88
+ video.pause();
89
+ video.src = `./skins/xianxian-living/clips/${clip.name}.webm`;
90
+ video.loop = clip.loop === true;
91
+ video.playbackRate = clip.rate ?? 1;
92
+ const placement = placeLivingClip(this.action, clip, this.root.dataset.dockEdge);
93
+ video.style.width = `${placement.width}px`;
94
+ video.style.height = `${placement.height}px`;
95
+ video.style.left = `${placement.left}px`;
96
+ video.style.top = `${placement.top}px`;
97
+ video.style.animation = clip.endGround
98
+ ? `living-wake-anchor ${clip.durationMs}ms linear forwards` : 'none';
99
+ if (clip.endGround) {
100
+ video.style.setProperty?.('--living-left-start', `${placement.left}px`);
101
+ video.style.setProperty?.('--living-top-start', `${placement.top}px`);
102
+ video.style.setProperty?.('--living-left-end', `${placement.endLeft}px`);
103
+ video.style.setProperty?.('--living-top-end', `${placement.endTop}px`);
104
+ }
105
+ try { video.currentTime = 0; } catch { /* New source is not yet seekable. */ }
106
+ video.onended = () => { if (revision === this.revision) onEnded(); };
107
+ }
108
+
109
+ play(video, revision, onEnded) {
110
+ void video.play().catch(() => {
111
+ if (revision !== this.revision) return;
112
+ this.hideVideos();
113
+ setHidden(this.sit, false);
114
+ onEnded();
115
+ });
116
+ }
117
+
118
+ hideVideos() {
119
+ for (const video of [this.video, this.standby]) {
120
+ this.retireVideo(video);
121
+ }
122
+ }
123
+
124
+ retireVideo(video) {
125
+ if (!video) return;
126
+ video.onended = null;
127
+ video.pause();
128
+ setHidden(video, true);
129
+ }
130
+
131
+ cancelFade() {
132
+ if (this.fadeTimer !== undefined) this.clearTimer(this.fadeTimer);
133
+ this.fadeTimer = undefined;
134
+ }
135
+
136
+ close() {
137
+ this.revision++;
138
+ this.cancelFade();
139
+ this.hideVideos();
140
+ }
141
+
142
+ hide() {
143
+ this.close();
144
+ setHidden(this.sit, true);
145
+ this.action = '';
146
+ }
147
+ }
@@ -0,0 +1,16 @@
1
+ /** Observe the Host window's current screen; never select or move a display. */
2
+ export function detectDockedEdge(win, petRect) {
3
+ const left = win.screen?.availLeft;
4
+ const width = win.screen?.availWidth;
5
+ if (![win.screenX, left, width, petRect.left, petRect.width].every(Number.isFinite) || width <= 0) return null;
6
+ const petLeft = win.screenX + petRect.left;
7
+ const edge = petLeft - left <= 40 ? 'left'
8
+ : left + width - petLeft - petRect.width <= 40 ? 'right' : null;
9
+ if (!edge) return null;
10
+ const top = win.screen?.availTop;
11
+ const height = win.screen?.availHeight;
12
+ const vertical = [win.screenY, top, height, petRect.top, petRect.height];
13
+ const atBottom = vertical.every(Number.isFinite) && height > 0
14
+ && top + height - (win.screenY + petRect.top + petRect.height) <= 40;
15
+ return { edge, zone: atBottom ? 'corner' : 'mid' };
16
+ }
@@ -0,0 +1,96 @@
1
+ // F317 living Xianxian package v3. All values are reviewed package CSS anchors;
2
+ // every clip uses one stage scale. content bounds are the union of decoded
3
+ // alpha pixels and exist to size the native transparent surface, not to apply
4
+ // per-action "looks right" scaling.
5
+ export const LIVING_GEOMETRY = Object.freeze({
6
+ pet: Object.freeze({ width: 120, height: 130 }),
7
+ stage: Object.freeze({ size: 290, centre: 145, ground: 262.5 }),
8
+ surface: Object.freeze({ left: -32, top: -18, right: 143, bottom: 138 }),
9
+ sitContent: Object.freeze({ left: 8.8, top: 3.4, right: 94.2, bottom: 109.2 }),
10
+ });
11
+
12
+ export const LIVING_SCALE = LIVING_GEOMETRY.pet.width / LIVING_GEOMETRY.stage.size;
13
+ export const LIVING_REFERENCE = Object.freeze({
14
+ centre: LIVING_GEOMETRY.stage.centre * LIVING_SCALE,
15
+ ground: LIVING_GEOMETRY.stage.ground * LIVING_SCALE,
16
+ });
17
+
18
+ const clip = (name, size, ground, centre, content, options = {}) => Object.freeze({
19
+ name, size: Object.freeze(size), ground, centre, content: Object.freeze(content), ...options,
20
+ });
21
+
22
+ const library = Object.freeze({
23
+ trot: clip('trot', [265, 236], 234.8, 132.5,
24
+ { left: 1.5, top: 2, right: 263.5, bottom: 235.5 }, { loop: true }),
25
+ pounce: clip('pounce', [304.8, 312.6], 303.9, 201.9,
26
+ { left: 3, top: 0, right: 302, bottom: 312 }, { rate: 1.5 }),
27
+ sleep: clip('sleep', [271.2, 168], 161, 133.2,
28
+ { left: 2.5, top: 2.5, right: 268.5, bottom: 166.5 }, { loop: true }),
29
+ wake: clip('wake', [400.2, 282.7], 244.2, 200.5,
30
+ { left: 3, top: 3, right: 397, bottom: 280.5 },
31
+ { endGround: 277.4, endCentre: 197.7, durationMs: 7_250, crossfadeMs: 250 }),
32
+ peek: clip('peek', [153.5, 234.3], 234.3, 76.75,
33
+ { left: 2.5, top: 2.5, right: 153, bottom: 234 }, { loop: true }),
34
+ peek_fade: clip('peek_fade', [153.5, 234.3], 234.3, 76.75,
35
+ { left: 2.5, top: 2.5, right: 153, bottom: 231.5 }, { loop: true }),
36
+ carry: clip('carry', [344.3, 290.9], 263, 198.3,
37
+ { left: 4, top: 4, right: 340, bottom: 288.5 }),
38
+ wait: clip('wait', [197.9, 252.8], 249.6, 98.4,
39
+ { left: 4, top: 4, right: 194, bottom: 251 }, { loop: true }),
40
+ think: clip('think', [266.4, 256.9], 253.7, 132.1,
41
+ { left: 3.5, top: 3.5, right: 262, bottom: 255 }, { loop: true }),
42
+ });
43
+
44
+ const actionClip = Object.freeze({
45
+ 'running-left': 'trot',
46
+ 'running-right': 'trot',
47
+ running: 'trot',
48
+ play: 'pounce',
49
+ sleeping: 'sleep',
50
+ waking: 'wake',
51
+ peek: 'peek',
52
+ delivering: 'carry',
53
+ review: 'carry',
54
+ pending_decision: 'wait',
55
+ working: 'think',
56
+ fetching: 'think',
57
+ studying: 'think',
58
+ staged_thought: 'think',
59
+ });
60
+
61
+ export const isLivingClip = action => Object.hasOwn(actionClip, action);
62
+
63
+ export function livingClipFor(action, dockZone) {
64
+ const name = action === 'peek' && dockZone !== 'corner' ? 'peek_fade' : actionClip[action];
65
+ return name ? library[name] : null;
66
+ }
67
+
68
+ export function placeLivingClip(action, selected, dockEdge) {
69
+ const width = selected.size[0] * LIVING_SCALE;
70
+ const height = selected.size[1] * LIVING_SCALE;
71
+ const left = action === 'peek'
72
+ ? (dockEdge === 'left' ? LIVING_GEOMETRY.surface.left : LIVING_GEOMETRY.surface.right - width)
73
+ : LIVING_REFERENCE.centre - selected.centre * LIVING_SCALE;
74
+ const top = action === 'peek'
75
+ ? LIVING_GEOMETRY.surface.bottom - height
76
+ : LIVING_REFERENCE.ground - selected.ground * LIVING_SCALE;
77
+ return Object.freeze({
78
+ left, top, width, height,
79
+ endLeft: selected.endCentre === undefined
80
+ ? undefined : LIVING_REFERENCE.centre - selected.endCentre * LIVING_SCALE,
81
+ endTop: selected.endGround === undefined
82
+ ? undefined : LIVING_REFERENCE.ground - selected.endGround * LIVING_SCALE,
83
+ });
84
+ }
85
+
86
+ export function livingContentRect(selected, placement, endpoint = 'start') {
87
+ const left = endpoint === 'end' ? placement.endLeft : placement.left;
88
+ const top = endpoint === 'end' ? placement.endTop : placement.top;
89
+ if (!Number.isFinite(left) || !Number.isFinite(top)) return null;
90
+ return Object.freeze({
91
+ left: left + selected.content.left * LIVING_SCALE,
92
+ top: top + selected.content.top * LIVING_SCALE,
93
+ right: left + selected.content.right * LIVING_SCALE,
94
+ bottom: top + selected.content.bottom * LIVING_SCALE,
95
+ });
96
+ }
@@ -0,0 +1,79 @@
1
+ const validId = value => typeof value === 'string' && value.length > 0 && value.length <= 256;
2
+ const validExpiry = (value, now) => Number.isFinite(value) && value > now;
3
+ const workKinds = new Set(['reasoning', 'tool', 'workspace', 'screen_reading']);
4
+
5
+ /** A bounded, expiring projection of Host-owned motion facts. */
6
+ export class MotionFacts {
7
+ constructor({ now, setTimer, clearTimer, changed }) {
8
+ this.now = now;
9
+ this.setTimer = setTimer;
10
+ this.clearTimer = clearTimer;
11
+ this.changed = changed;
12
+ this.snapshot = { work: null, delivery: null, travel: null };
13
+ this.seenResults = new Map();
14
+ this.suppressedTravel = new Map();
15
+ }
16
+
17
+ sync(value, suppressTravel = false) {
18
+ const now = this.now();
19
+ this.prune(now);
20
+ const input = value && typeof value === 'object' ? value : {};
21
+ const work = input.work;
22
+ const delivery = input.delivery;
23
+ const travel = input.travel;
24
+ this.snapshot = {
25
+ work: work && validId(work.taskId) && work.status === 'active' && workKinds.has(work.kind)
26
+ && validExpiry(work.expiresAt, now)
27
+ ? { taskId: work.taskId, kind: work.kind, expiresAt: work.expiresAt } : null,
28
+ delivery: delivery && validId(delivery.resultId) && delivery.status === 'applied'
29
+ && validExpiry(delivery.expiresAt, now)
30
+ ? { resultId: delivery.resultId, expiresAt: delivery.expiresAt } : null,
31
+ travel: travel && validId(travel.eventId) && travel.status === 'active' && Number.isFinite(travel.dx)
32
+ && Number.isFinite(travel.dy) && Math.hypot(travel.dx, travel.dy) >= 1
33
+ && validExpiry(travel.expiresAt, now) && !this.suppressedTravel.has(travel.eventId)
34
+ ? { eventId: travel.eventId, dx: travel.dx, dy: travel.dy, expiresAt: travel.expiresAt } : null,
35
+ };
36
+ if (suppressTravel) this.suppressTravel();
37
+ this.scheduleExpiry();
38
+ }
39
+
40
+ consumeDelivery() {
41
+ const delivery = this.snapshot.delivery;
42
+ if (!delivery || this.seenResults.has(delivery.resultId)) return null;
43
+ this.seenResults.set(delivery.resultId, this.now() + 3_600_000);
44
+ return delivery;
45
+ }
46
+
47
+ suppressTravel() {
48
+ if (!this.snapshot.travel) return;
49
+ this.suppressedTravel.set(this.snapshot.travel.eventId, this.now() + 3_600_000);
50
+ this.snapshot.travel = null;
51
+ this.scheduleExpiry();
52
+ }
53
+
54
+ close() {
55
+ if (this.timer !== undefined) this.clearTimer(this.timer);
56
+ this.timer = undefined;
57
+ }
58
+
59
+ scheduleExpiry() {
60
+ this.close();
61
+ const expiries = Object.values(this.snapshot).filter(Boolean).map(value => value.expiresAt);
62
+ if (!expiries.length) return;
63
+ this.timer = this.setTimer(() => {
64
+ this.timer = undefined;
65
+ const now = this.now();
66
+ for (const key of ['work', 'delivery', 'travel']) {
67
+ if (this.snapshot[key]?.expiresAt <= now) this.snapshot[key] = null;
68
+ }
69
+ this.scheduleExpiry();
70
+ this.changed();
71
+ }, Math.max(0, Math.min(...expiries) - this.now()));
72
+ }
73
+
74
+ prune(now) {
75
+ for (const collection of [this.seenResults, this.suppressedTravel]) {
76
+ for (const [id, expiresAt] of collection) if (expiresAt <= now) collection.delete(id);
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,24 @@
1
+ const priority = { reasoning: 1, tool: 2, workspace_fetch: 3, workspace_dispatch: 4, screen_read: 5 };
2
+ const semantic = {
3
+ reasoning: 'reasoning',
4
+ tool: 'tool',
5
+ workspace_fetch: 'workspace',
6
+ workspace_dispatch: 'workspace',
7
+ screen_read: 'screen_reading',
8
+ };
9
+
10
+ /** Normalize the Host's bounded call snapshot; never infer work from chat text. */
11
+ export function normalizeNativeWork(value, now = Date.now()) {
12
+ if (!value || typeof value !== 'object' || !Array.isArray(value.active) || !Array.isArray(value.recent)) return null;
13
+ const active = value.active
14
+ .filter(entry => entry && semantic[entry.kind] && Number.isFinite(entry.expiresAt) && entry.expiresAt > now)
15
+ .sort((left, right) => priority[right.kind] - priority[left.kind] || right.startedAt - left.startedAt)[0];
16
+ const handed = [...value.recent].reverse().find(event => event?.kind === 'result'
17
+ && event.phase === 'result_handed_to_voice' && typeof event.resultId === 'string'
18
+ && event.resultId.length > 0 && Number.isFinite(event.expiresAt) && event.expiresAt > now);
19
+ return {
20
+ work: active ? { taskId: active.taskId, kind: semantic[active.kind], status: 'active', expiresAt: active.expiresAt } : null,
21
+ delivery: handed ? { resultId: handed.resultId, status: 'applied', expiresAt: handed.expiresAt } : null,
22
+ travel: null,
23
+ };
24
+ }
@@ -0,0 +1,84 @@
1
+ /** Transient controls. The Host owns native placement, movement and hiding. */
2
+ export function bindPetControls(client, { action, error, changed, moved = () => {} }) {
3
+ const $ = id => document.getElementById(id);
4
+ const body = $('pet');
5
+ let panel = 'none', ambient = false, layoutRevision = 0, drag, suppressClick = false;
6
+ async function show(next) {
7
+ if (next === 'none' && ambient) next = 'bubble';
8
+ if (panel !== next && $(next)) $(next).style.maxHeight = '500px';
9
+ panel = next;
10
+ for (const id of ['actions', 'menu', 'chat', 'bubble', 'decisions']) $(id).hidden = id !== next;
11
+ body.setAttribute('aria-expanded', String(!['none', 'bubble'].includes(next)));
12
+ const node = $(next);
13
+ const revision = ++layoutRevision;
14
+ try {
15
+ const placed = await client.layout(next, Math.max(120, Math.ceil(node?.offsetWidth ?? 120)), Math.max(32, Math.ceil(node?.offsetHeight ?? 130)));
16
+ if (revision !== layoutRevision) return;
17
+ $('anchor').style.left = `${placed.pet.x}px`; $('anchor').style.top = `${placed.pet.y}px`;
18
+ if (node) { node.style.left = `${placed.panel.x}px`; node.style.top = `${placed.panel.y}px`; node.style.maxHeight = `${placed.panel.height}px`; }
19
+ if (next === 'chat') $('message').focus({ preventScroll: true });
20
+ if (next === 'decisions') $('decision-reload').focus({ preventScroll: true });
21
+ if (next === 'menu') $('menu').querySelector('button').focus({ preventScroll: true });
22
+ changed(next);
23
+ } catch (cause) { error(cause); }
24
+ }
25
+ body.onclick = event => {
26
+ if (suppressClick) { suppressClick = false; return; }
27
+ if (event.detail > 1) return;
28
+ void show(panel === 'actions' ? 'none' : 'actions');
29
+ };
30
+ body.oncontextmenu = event => { event.preventDefault(); void show('menu'); };
31
+ body.onkeydown = event => {
32
+ if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) { event.preventDefault(); void show('menu'); }
33
+ };
34
+ body.onpointerdown = event => {
35
+ if (event.button !== 0) return;
36
+ const current = { id: event.pointerId, x: event.screenX, y: event.screenY, armed: false };
37
+ drag = current;
38
+ body.setPointerCapture(event.pointerId);
39
+ void client.drag('start').then(() => { if (drag === current) current.armed = true; }).catch(error);
40
+ };
41
+ body.onpointermove = event => {
42
+ if (!drag) return;
43
+ const dx = event.screenX - drag.x, dy = event.screenY - drag.y;
44
+ if (Math.hypot(dx, dy) < 6) return;
45
+ suppressClick = true;
46
+ if (drag.armed) moved(dx, dy);
47
+ drag.x = event.screenX; drag.y = event.screenY;
48
+ };
49
+ for (const name of ['pointerup', 'pointercancel']) body.addEventListener(name, () => {
50
+ drag = undefined; moved('stop'); void client.drag('end').catch(error);
51
+ });
52
+ document.addEventListener('pointerdown', event => {
53
+ if (!event.target.closest('#anchor, .floating')) void show('none');
54
+ });
55
+ document.addEventListener('keydown', event => {
56
+ if (event.key === 'Escape' && panel !== 'none') { event.preventDefault(); void show('none'); body.focus(); }
57
+ });
58
+ document.querySelectorAll('[data-action]').forEach(button => button.onclick = () => {
59
+ const kind = button.dataset.action;
60
+ if (kind === 'write' || kind === 'history') { void show('chat'); return; }
61
+ if (kind === 'decisions') { void show('decisions'); return; }
62
+ if (kind === 'dismiss') { void show('none'); return; }
63
+ action(kind);
64
+ });
65
+ const sizes = new WeakMap();
66
+ const observer = new ResizeObserver(entries => {
67
+ for (const entry of entries) {
68
+ const size = `${entry.contentRect.width}:${entry.contentRect.height}`;
69
+ const previous = sizes.get(entry.target); sizes.set(entry.target, size);
70
+ if (previous && previous !== size && entry.target.id === panel && !entry.target.hidden) void show(panel);
71
+ }
72
+ });
73
+ for (const id of ['actions', 'menu', 'chat', 'bubble', 'decisions']) observer.observe($(id));
74
+ window.addEventListener('beforeunload', () => observer.disconnect());
75
+ return {
76
+ get panel() { return panel; }, show,
77
+ setAmbient(enabled) {
78
+ if (ambient === enabled) return Promise.resolve();
79
+ ambient = enabled;
80
+ return ['none', 'bubble'].includes(panel) ? show('none') : Promise.resolve();
81
+ },
82
+ dismiss() { if (drag) suppressClick = true; void show('none'); },
83
+ };
84
+ }