@clowder-ai/companion 0.1.0-alpha.2 → 0.1.0-alpha.21

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 (61) hide show
  1. package/NOTICE +10 -2
  2. package/README.md +59 -0
  3. package/manifest.json +5 -5
  4. package/package.json +19 -6
  5. package/plugin.yaml +5 -5
  6. package/renderer/call-transcript-view.mjs +238 -0
  7. package/renderer/chat-unread.mjs +44 -0
  8. package/renderer/client.mjs +15 -1
  9. package/renderer/compact-size.mjs +8 -0
  10. package/renderer/companion-identity.mjs +42 -0
  11. package/renderer/conversation.mjs +144 -24
  12. package/renderer/decision-panel.mjs +122 -0
  13. package/renderer/decision-view.mjs +124 -0
  14. package/renderer/errors.mjs +4 -4
  15. package/renderer/index.html +188 -26
  16. package/renderer/living-body.mjs +147 -0
  17. package/renderer/living-edge.mjs +16 -0
  18. package/renderer/living-geometry.mjs +96 -0
  19. package/renderer/motion-facts.mjs +108 -0
  20. package/renderer/native-window-travel.mjs +89 -0
  21. package/renderer/native-work-motion.mjs +39 -0
  22. package/renderer/peer.mjs +1 -1
  23. package/renderer/pet-controls.mjs +130 -0
  24. package/renderer/pet-motion-assets.mjs +22 -0
  25. package/renderer/pet-motion.mjs +343 -0
  26. package/renderer/recent-bubble.mjs +74 -0
  27. package/renderer/settings-controller.mjs +171 -0
  28. package/renderer/settings-view.mjs +234 -0
  29. package/renderer/skins/ragdoll-v1.png +0 -0
  30. package/renderer/skins/xianxian-codex.webp +0 -0
  31. package/renderer/skins/xianxian-living/clips/carry.webm +0 -0
  32. package/renderer/skins/xianxian-living/clips/peek.webm +0 -0
  33. package/renderer/skins/xianxian-living/clips/peek_fade.webm +0 -0
  34. package/renderer/skins/xianxian-living/clips/pounce.webm +0 -0
  35. package/renderer/skins/xianxian-living/clips/sleep.webm +0 -0
  36. package/renderer/skins/xianxian-living/clips/think.webm +0 -0
  37. package/renderer/skins/xianxian-living/clips/trot.webm +0 -0
  38. package/renderer/skins/xianxian-living/clips/wait.webm +0 -0
  39. package/renderer/skins/xianxian-living/clips/wake.webm +0 -0
  40. package/renderer/skins/xianxian-living/layers/body.webp +0 -0
  41. package/renderer/skins/xianxian-living/layers/closed.webp +0 -0
  42. package/renderer/skins/xianxian-living/layers/earL.webp +0 -0
  43. package/renderer/skins/xianxian-living/layers/earR.webp +0 -0
  44. package/renderer/skins/xianxian-living/layers/head.webp +0 -0
  45. package/renderer/skins/xianxian-living/layers/irisL.webp +0 -0
  46. package/renderer/skins/xianxian-living/layers/irisR.webp +0 -0
  47. package/renderer/skins/xianxian-living/layers/meta.json +88 -0
  48. package/renderer/skins/xianxian-living/layers/mouth.webp +0 -0
  49. package/renderer/skins/xianxian-living/layers/scared.webp +0 -0
  50. package/renderer/skins/xianxian-living/layers/tail.webp +0 -0
  51. package/renderer/skins/xianxian-living/layers/whiteL.webp +0 -0
  52. package/renderer/skins/xianxian-living/layers/whiteR.webp +0 -0
  53. package/renderer/skins/xianxian-sleeping-row.png +0 -0
  54. package/renderer/skins/xianxian-staged-thought-row.png +0 -0
  55. package/renderer/skins/xianxian-working-row.png +0 -0
  56. package/renderer/skins/yarn-ball.png +0 -0
  57. package/renderer/style.css +260 -308
  58. package/renderer/surface.mjs +306 -69
  59. package/renderer/transcript-view.mjs +60 -7
  60. package/renderer/work-overview.mjs +95 -0
  61. package/source-lock.json +138 -4
@@ -0,0 +1,89 @@
1
+ const validPosition = value => value && Number.isFinite(value.x) && Number.isFinite(value.y);
2
+
3
+ /** Observe the native window itself. Running animation follows measured desktop
4
+ * movement; renderer state never invents a target or claims a move succeeded. */
5
+ export class NativeWindowTravel {
6
+ constructor({
7
+ readPosition,
8
+ changed,
9
+ now = Date.now,
10
+ setTimer = globalThis.setTimeout,
11
+ clearTimer = globalThis.clearTimeout,
12
+ intervalMs = 80,
13
+ expiresMs = 240,
14
+ }) {
15
+ this.readPosition = readPosition;
16
+ this.changed = changed;
17
+ this.now = now;
18
+ this.setTimer = (callback, delay) => setTimer.call(globalThis, callback, delay);
19
+ this.clearTimer = id => clearTimer.call(globalThis, id);
20
+ this.intervalMs = Math.max(32, intervalMs);
21
+ this.expiresMs = Math.max(this.intervalMs, expiresMs);
22
+ this.sequence = 0;
23
+ this.manual = false;
24
+ this.closed = false;
25
+ this.active = null;
26
+ }
27
+
28
+ start() {
29
+ if (this.closed || this.timer !== undefined) return;
30
+ this.previous = this.read();
31
+ this.schedule();
32
+ }
33
+
34
+ setManual(value) {
35
+ const manual = value === true;
36
+ if (manual === this.manual) return;
37
+ this.manual = manual;
38
+ this.previous = this.read();
39
+ if (manual) this.clearActive();
40
+ }
41
+
42
+ tick() {
43
+ this.timer = undefined;
44
+ if (this.closed) return;
45
+ const next = this.read();
46
+ const now = this.now();
47
+ if (next && this.previous) {
48
+ const dx = next.x - this.previous.x;
49
+ const dy = next.y - this.previous.y;
50
+ if (!this.manual && Math.hypot(dx, dy) >= 1) {
51
+ const eventId = this.active?.eventId ?? `native-${Math.trunc(now)}-${++this.sequence}`;
52
+ this.active = { eventId, status: 'active', dx, dy, expiresAt: now + this.expiresMs };
53
+ this.changed({ ...this.active });
54
+ } else if (this.active && this.active.expiresAt <= now) {
55
+ this.clearActive();
56
+ }
57
+ }
58
+ this.previous = next;
59
+ this.schedule();
60
+ }
61
+
62
+ close() {
63
+ if (this.closed) return;
64
+ this.closed = true;
65
+ if (this.timer !== undefined) this.clearTimer(this.timer);
66
+ this.timer = undefined;
67
+ this.clearActive();
68
+ }
69
+
70
+ read() {
71
+ try {
72
+ const value = this.readPosition();
73
+ return validPosition(value) ? { x: value.x, y: value.y } : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ schedule() {
80
+ if (this.closed || this.timer !== undefined) return;
81
+ this.timer = this.setTimer(() => this.tick(), this.intervalMs);
82
+ }
83
+
84
+ clearActive() {
85
+ if (!this.active) return;
86
+ this.active = null;
87
+ this.changed(null);
88
+ }
89
+ }
@@ -0,0 +1,39 @@
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
+ }
25
+
26
+ const workLabels = {
27
+ reasoning: '正在思考',
28
+ tool: '正在处理',
29
+ workspace: '正在处理家里的事务',
30
+ screen_reading: '正在查看共享画面',
31
+ };
32
+
33
+ /** Product wording for the same bounded work fact used by body motion. */
34
+ export function nativeWorkLabel(snapshot, nativeActivity = 'none') {
35
+ const kind = snapshot
36
+ ? snapshot.work?.kind
37
+ : nativeActivity === 'reasoning' ? 'reasoning' : nativeActivity === 'tool_running' ? 'tool' : null;
38
+ return workLabels[kind] ?? '';
39
+ }
package/renderer/peer.mjs CHANGED
@@ -10,7 +10,7 @@ export class VoicePeer {
10
10
  }
11
11
  });
12
12
  }
13
- async connect() { if (!this.closed) await this.client.connectAudio(); }
13
+ async connect(mode) { if (!this.closed) await this.client.connectAudio(mode); }
14
14
  muteMic(muted) { void this.client.muteMicrophone(muted).catch(() => this.failed()); }
15
15
  muteSpeaker(muted) { void this.client.muteSpeaker(muted).catch(() => this.failed()); }
16
16
  failed() { if (!this.closed) this.onEvent({ type: 'error', code: 'unavailable' }); }
@@ -0,0 +1,130 @@
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
+ const panelIds = ['actions', 'menu', 'chat', 'bubble', 'decisions', 'transcript', 'settings'];
6
+ const desiredSizes = new WeakMap();
7
+ let panel = 'none', ambientPanel = 'none', layoutRevision = 0, layoutScheduled = false, drag, suppressClick = false;
8
+ const measure = node => {
9
+ if (!node) return { width: 120, height: 130 };
10
+ // Host placement is an applied cap, not the panel's desired geometry.
11
+ // Release it synchronously while measuring so CSS height and nested flex
12
+ // overflow can expose their natural demand, then restore it before paint.
13
+ const appliedMaxHeight = node.style.maxHeight;
14
+ node.style.maxHeight = '';
15
+ const desired = {
16
+ width: Math.min(420, Math.max(120, Math.ceil(Math.max(node.offsetWidth, node.scrollWidth)))),
17
+ height: Math.min(500, Math.max(32, Math.ceil(Math.max(node.offsetHeight, node.scrollHeight)))),
18
+ };
19
+ node.style.maxHeight = appliedMaxHeight;
20
+ return desired;
21
+ };
22
+ async function show(next) {
23
+ if (next === 'none' && ambientPanel !== 'none') next = ambientPanel;
24
+ const previousPanel = panel;
25
+ const node = $(next);
26
+ panel = next;
27
+ for (const id of panelIds) $(id).hidden = id !== next;
28
+ body.setAttribute('aria-expanded', String(!['none', 'bubble'].includes(next)));
29
+ const desired = measure(node);
30
+ if (node) desiredSizes.set(node, `${desired.width}:${desired.height}`);
31
+ const revision = ++layoutRevision;
32
+ try {
33
+ const placed = await client.layout(next, desired.width, desired.height);
34
+ if (revision !== layoutRevision) return;
35
+ $('anchor').style.left = `${placed.pet.x}px`; $('anchor').style.top = `${placed.pet.y}px`;
36
+ if (node) { node.style.left = `${placed.panel.x}px`; node.style.top = `${placed.panel.y}px`; node.style.maxHeight = `${placed.panel.height}px`; }
37
+ if (previousPanel !== next) {
38
+ if (next === 'chat') $('message').focus({ preventScroll: true });
39
+ if (next === 'transcript') $('call-message').focus({ preventScroll: true });
40
+ if (next === 'settings') $('settings-back').focus({ preventScroll: true });
41
+ if (next === 'decisions') $('decision-reload').focus({ preventScroll: true });
42
+ if (next === 'menu') $('menu').querySelector('button').focus({ preventScroll: true });
43
+ changed(next);
44
+ }
45
+ } catch (cause) {
46
+ if (revision !== layoutRevision) return;
47
+ panel = previousPanel;
48
+ for (const id of panelIds) $(id).hidden = id !== panel;
49
+ body.setAttribute('aria-expanded', String(!['none', 'bubble'].includes(panel)));
50
+ error(cause, { panel: next });
51
+ }
52
+ }
53
+ function remeasure() {
54
+ if (layoutScheduled) return;
55
+ layoutScheduled = true;
56
+ queueMicrotask(() => {
57
+ layoutScheduled = false;
58
+ const node = $(panel);
59
+ if (!node || node.hidden) return;
60
+ const desired = measure(node);
61
+ const key = `${desired.width}:${desired.height}`;
62
+ if (desiredSizes.get(node) === key) return;
63
+ void show(panel);
64
+ });
65
+ }
66
+ body.onclick = event => {
67
+ if (suppressClick) { suppressClick = false; return; }
68
+ if (event.detail > 1) return;
69
+ void show(panel === 'menu' ? 'none' : 'menu');
70
+ };
71
+ body.oncontextmenu = event => { event.preventDefault(); void show('menu'); };
72
+ body.onkeydown = event => {
73
+ if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) { event.preventDefault(); void show('menu'); }
74
+ };
75
+ body.onpointerdown = event => {
76
+ if (event.button !== 0) return;
77
+ const current = { id: event.pointerId, x: event.screenX, y: event.screenY, armed: false };
78
+ drag = current;
79
+ body.setPointerCapture(event.pointerId);
80
+ void client.drag('start').then(() => { if (drag === current) current.armed = true; }).catch(error);
81
+ };
82
+ body.onpointermove = event => {
83
+ if (!drag) return;
84
+ const dx = event.screenX - drag.x, dy = event.screenY - drag.y;
85
+ if (Math.hypot(dx, dy) < 6) return;
86
+ suppressClick = true;
87
+ if (drag.armed) moved(dx, dy);
88
+ drag.x = event.screenX; drag.y = event.screenY;
89
+ };
90
+ for (const name of ['pointerup', 'pointercancel']) body.addEventListener(name, () => {
91
+ drag = undefined; moved('stop'); void client.drag('end').catch(error);
92
+ });
93
+ document.addEventListener('pointerdown', event => {
94
+ if (!event.target.closest('#anchor, .floating')) void show('none');
95
+ });
96
+ document.addEventListener('keydown', event => {
97
+ if (event.key === 'Escape' && panel !== 'none') { event.preventDefault(); void show('none'); body.focus(); }
98
+ });
99
+ document.querySelectorAll('[data-action]').forEach(button => button.onclick = () => {
100
+ const kind = button.dataset.action;
101
+ if (kind === 'write' || kind === 'history') { void show('chat'); return; }
102
+ if (kind === 'transcript') { void show('transcript'); return; }
103
+ if (kind === 'settings') { void show('settings'); return; }
104
+ if (kind === 'decisions') { void show('decisions'); return; }
105
+ if (kind === 'dismiss') { void show('none'); return; }
106
+ action(kind);
107
+ });
108
+ const observer = new MutationObserver(records => {
109
+ const current = $(panel);
110
+ if (!current || current.hidden) return;
111
+ // Root visibility is already handled by show(). Descendant visibility and
112
+ // text/row mutations are content demand and may require a larger surface.
113
+ if (records.some(record => record.type !== 'attributes' || !panelIds.includes(record.target.id))) remeasure();
114
+ });
115
+ for (const id of panelIds) observer.observe($(id), {
116
+ subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['hidden'],
117
+ });
118
+ window.addEventListener('beforeunload', () => observer.disconnect());
119
+ return {
120
+ get panel() { return panel; }, show, remeasure,
121
+ setAmbient(value) {
122
+ const next = value === true ? 'bubble' : value === false ? 'none' : value;
123
+ if (!['none', 'bubble', 'actions'].includes(next)) return Promise.resolve();
124
+ if (ambientPanel === next) return Promise.resolve();
125
+ ambientPanel = next;
126
+ return ['none', 'bubble', 'actions'].includes(panel) ? show('none') : Promise.resolve();
127
+ },
128
+ dismiss() { if (drag) suppressClick = true; void show('none'); },
129
+ };
130
+ }
@@ -0,0 +1,22 @@
1
+ // F229 pet.json frame durations, plus the three F258 rows pinned in source-lock.json.
2
+ export const atlas = {
3
+ idle: { row: 0, frames: [1200, 400, 400, 500, 500, 800] },
4
+ 'running-right': { row: 1, frames: [120, 120, 120, 120, 120, 120, 120, 220] },
5
+ 'running-left': { row: 2, frames: [120, 120, 120, 120, 120, 120, 120, 220] },
6
+ waving: { row: 3, frames: [140, 140, 140, 280] },
7
+ jumping: { row: 4, frames: [140, 140, 140, 140, 280] },
8
+ failed: { row: 5, frames: [140, 140, 140, 140, 140, 140, 140, 240] },
9
+ waiting: { row: 6, frames: [150, 150, 150, 150, 150, 260] },
10
+ running: { row: 7, frames: [120, 120, 120, 120, 120, 220] },
11
+ review: { row: 8, frames: [150, 150, 150, 150, 150, 280] },
12
+ };
13
+ export const additional = {
14
+ sleeping: { file: 'xianxian-sleeping-row.png', frames: [700, 700, 700, 700, 700, 700] },
15
+ working: { file: 'xianxian-working-row.png', frames: [520, 560, 520, 640, 520, 560, 520, 640] },
16
+ staged_thought: { file: 'xianxian-staged-thought-row.png', frames: [1200, 500, 700, 900, 1200, 500] },
17
+ };
18
+ export const visualAlias = { fetching: 'working', studying: 'staged_thought', delivering: 'review', held: 'idle' };
19
+ export const workAction = { reasoning: 'staged_thought', tool: 'working', workspace: 'fetching', screen_reading: 'studying' };
20
+ export const signals = { connected: 'waving', received: 'jumping', failed: 'failed' };
21
+ export const staticSkins = { 'ragdoll-v1': 'ragdoll-v1.png', 'yarn-ball': 'yarn-ball.png' };
22
+ export const validId = value => typeof value === 'string' && value.length > 0 && value.length <= 256;
@@ -0,0 +1,343 @@
1
+ import { isLivingClip } from './living-body.mjs';
2
+ import { MotionFacts } from './motion-facts.mjs';
3
+ import { additional, atlas, signals, staticSkins, validId, visualAlias, workAction } from './pet-motion-assets.mjs';
4
+ /** Select one truthful body from Host facts and local, user-controlled gestures. */
5
+ export class PetMotion {
6
+ constructor(element, options = {}) {
7
+ this.element = element;
8
+ const setTimer = options.setTimer ?? globalThis.setTimeout;
9
+ const clearTimer = options.clearTimer ?? globalThis.clearTimeout;
10
+ this.setTimer = (callback, delay) => setTimer.call(globalThis, callback, delay);
11
+ this.clearTimer = id => clearTimer.call(globalThis, id);
12
+ this.now = options.now ?? Date.now;
13
+ this.livingBody = options.livingBody;
14
+ this.reducedMotion = options.reducedMotion ?? globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
15
+ this.autoPlay = options.autoPlay === true;
16
+ this.autoPlayIdleMs = Math.max(90_000, options.autoPlayIdleMs ?? 90_000);
17
+ this.autoPlayCooldownMs = Math.max(180_000, options.autoPlayCooldownMs ?? 180_000);
18
+ this.lastPlayAt = Number.NEGATIVE_INFINITY;
19
+ this.skin = 'xianxian-codex';
20
+ this.phase = 'idle';
21
+ this.base = 'idle';
22
+ this.action = '';
23
+ this.frame = 0;
24
+ this.dragging = false;
25
+ this.oneShot = false;
26
+ this.pendingDecision = false;
27
+ this.dockedEdge = null;
28
+ this.dockZone = null;
29
+ this.resting = false;
30
+ this.transient = null;
31
+ this.facts = new MotionFacts({ now: this.now, setTimer: this.setTimer, clearTimer: this.clearTimer,
32
+ changed: () => this.refresh() });
33
+ this.seenEvents = new Map();
34
+ }
35
+
36
+ setContext({ skin, phase, nativeActivity, muted }) {
37
+ const selectedSkin = ['xianxian-codex', 'yanyan-codex', ...Object.keys(staticSkins)].includes(skin)
38
+ ? skin : 'xianxian-codex';
39
+ const wasSleeping = this.action === 'sleeping' || this.resting;
40
+ this.context = { skin: selectedSkin, phase, nativeActivity, muted };
41
+ const changedSkin = selectedSkin !== this.skin;
42
+ this.skin = selectedSkin;
43
+ this.phase = phase;
44
+ if (phase !== 'idle' || nativeActivity !== 'none') {
45
+ this.resting = false;
46
+ this.cancelIdleTimers();
47
+ }
48
+ if (nativeActivity !== 'none' && this.transient?.action !== 'failed') this.transient = null;
49
+ else if (wasSleeping && ['talking', 'connecting'].includes(phase) && !muted)
50
+ this.transient = { action: 'waking' };
51
+ this.refresh(changedSkin);
52
+ }
53
+ setBehaviorEnabled(enabled) {
54
+ const next = enabled === true;
55
+ if (this.autoPlay === next) return;
56
+ this.autoPlay = next;
57
+ if (!next) {
58
+ if (this.autoTimer !== undefined) this.clearTimer(this.autoTimer);
59
+ this.autoTimer = undefined;
60
+ if (this.transient?.automatic) {
61
+ this.transient = null;
62
+ this.refresh();
63
+ }
64
+ return;
65
+ }
66
+ this.refresh();
67
+ }
68
+ syncSnapshot(value) {
69
+ this.syncFacts(value, false);
70
+ }
71
+ syncNativeSnapshot(value) {
72
+ this.syncFacts(value, true);
73
+ }
74
+ syncFacts(value, nativeOnly) {
75
+ const now = this.now();
76
+ this.hasNativeWork = value !== null && value !== undefined;
77
+ for (const [id, expiresAt] of this.seenEvents) if (expiresAt <= now) this.seenEvents.delete(id);
78
+ if (nativeOnly) this.facts.syncNative(value);
79
+ else this.facts.sync(value, this.dragging);
80
+ if (this.snapshot.work) {
81
+ this.resting = false;
82
+ this.cancelIdleTimers();
83
+ if (this.transient?.action !== 'failed') this.transient = null;
84
+ } else if (this.resting && (this.snapshot.delivery || this.snapshot.travel)) {
85
+ this.resting = false;
86
+ this.cancelRest();
87
+ this.transient = { action: 'waking' };
88
+ }
89
+ this.refresh();
90
+ }
91
+ syncTravel(value) {
92
+ this.facts.syncTravel(value, this.dragging);
93
+ if (this.resting && this.snapshot.travel) {
94
+ this.resting = false;
95
+ this.cancelRest();
96
+ this.transient = { action: 'waking' };
97
+ }
98
+ this.refresh();
99
+ }
100
+ setPendingDecision(value) {
101
+ const pending = value === true;
102
+ if (this.pendingDecision === pending) return;
103
+ this.pendingDecision = pending;
104
+ if (pending) {
105
+ this.resting = false;
106
+ this.transient = null;
107
+ this.cancelIdleTimers();
108
+ }
109
+ this.refresh();
110
+ }
111
+ setDockedEdge(value) {
112
+ const candidate = typeof value === 'string' ? { edge: value, zone: 'corner' } : value;
113
+ const edge = ['left', 'right'].includes(candidate?.edge) ? candidate.edge : null;
114
+ const zone = edge && ['corner', 'mid'].includes(candidate?.zone) ? candidate.zone : edge ? 'corner' : null;
115
+ if (this.dockedEdge === edge && this.dockZone === zone) return;
116
+ this.dockedEdge = edge;
117
+ this.dockZone = zone;
118
+ if (edge) this.cancelIdleTimers();
119
+ this.refresh(this.action === 'peek');
120
+ }
121
+ signal(kind, eventId) {
122
+ if (kind === 'answered') return;
123
+ if (kind === 'connected' && this.living()) return;
124
+ if (kind === 'play') {
125
+ if (!this.canPlay()) return;
126
+ this.lastPlayAt = this.now();
127
+ this.resting = false;
128
+ this.cancelIdleTimers();
129
+ this.transient = { action: 'play', automatic: false };
130
+ this.refresh();
131
+ return;
132
+ }
133
+ let requested = signals[kind];
134
+ if (kind === 'received' && (this.action === 'sleeping' || this.resting)) requested = 'waking';
135
+ if (!requested || this.dragging || staticSkins[this.skin]) return;
136
+ const now = this.now();
137
+ if (validId(eventId)) {
138
+ if (this.seenEvents.has(eventId)) return;
139
+ this.seenEvents.set(eventId, now + 3_600_000);
140
+ }
141
+ this.resting = false;
142
+ this.cancelIdleTimers();
143
+ this.transient = { action: requested };
144
+ this.refresh();
145
+ }
146
+ move(dx, dy) {
147
+ if (!Number.isFinite(dx) || !Number.isFinite(dy) || Math.hypot(dx, dy) < 6) return;
148
+ if (this.dragging) return;
149
+ this.dragging = true;
150
+ this.element.classList?.add('dragging');
151
+ this.element.dataset.interaction = 'held-candidate';
152
+ this.facts.suppressTravel();
153
+ this.resting = false;
154
+ this.transient = null;
155
+ this.cancelIdleTimers();
156
+ this.refresh();
157
+ }
158
+ stopMove() {
159
+ if (!this.dragging) return;
160
+ this.dragging = false;
161
+ this.element.classList?.remove('dragging');
162
+ delete this.element.dataset.interaction;
163
+ this.refresh();
164
+ }
165
+ close() {
166
+ this.cancelFrame();
167
+ this.cancelIdleTimers();
168
+ this.facts.close();
169
+ this.livingBody?.close();
170
+ }
171
+ living() { return this.skin === 'xianxian-codex' && Boolean(this.livingBody); }
172
+ resolve(action) {
173
+ if (action === 'pending_decision' && !this.living()) return 'waiting';
174
+ if (action === 'peek' && !this.living()) return 'idle';
175
+ if (staticSkins[this.skin]) return 'idle';
176
+ const visual = visualAlias[action] ?? action;
177
+ if (this.skin !== 'xianxian-codex' && visual in additional)
178
+ return visual === 'sleeping' ? 'idle' : 'review';
179
+ return action;
180
+ }
181
+ refresh(force = false) {
182
+ this.element.dataset.listening = String(this.listening());
183
+ const selection = this.select();
184
+ this.base = this.baseAction();
185
+ const action = this.resolve(selection.action);
186
+ const changed = action !== this.action;
187
+ if (changed || force || selection.oneShot !== this.oneShot) {
188
+ this.oneShot = selection.oneShot;
189
+ this.play(action, force);
190
+ }
191
+ if (action === 'idle') this.armRest();
192
+ else if (action !== 'sleeping') this.cancelRest();
193
+ }
194
+ select() {
195
+ if (this.dragging) return { action: 'held', oneShot: false };
196
+ if (this.transient?.action === 'failed') return { action: 'failed', oneShot: true };
197
+ if (this.pendingDecision) return { action: 'pending_decision', oneShot: false };
198
+ const work = this.activeWorkAction();
199
+ if (work) return { action: work, oneShot: false };
200
+ if (this.transient && this.transient.action !== 'play')
201
+ return { action: this.transient.action, oneShot: true };
202
+ const delivery = this.facts.consumeDelivery();
203
+ if (delivery) {
204
+ this.transient = { action: 'delivering', resultId: delivery.resultId };
205
+ return { action: 'delivering', oneShot: true };
206
+ }
207
+ if (!this.reducedMotion && this.snapshot.travel) {
208
+ const { dx, dy } = this.snapshot.travel;
209
+ return { action: Math.abs(dx) >= Math.abs(dy) ? dx < 0 ? 'running-left' : 'running-right' : 'running', oneShot: false };
210
+ }
211
+ if (this.transient?.action === 'play') return { action: 'play', oneShot: true };
212
+ if (this.dockedEdge) return { action: 'peek', oneShot: false };
213
+ if (this.listening()) return { action: 'waiting', oneShot: false };
214
+ if (this.resting) return { action: 'sleeping', oneShot: false };
215
+ return { action: 'idle', oneShot: false };
216
+ }
217
+ baseAction() {
218
+ if (this.pendingDecision) return 'pending_decision';
219
+ const work = this.activeWorkAction();
220
+ if (work) return work;
221
+ if (this.dockedEdge) return 'peek';
222
+ if (this.listening()) return 'waiting';
223
+ return 'idle';
224
+ }
225
+
226
+ listening() { return ['talking', 'connecting'].includes(this.phase) && !this.context?.muted; }
227
+ activeWorkAction() {
228
+ if (this.snapshot.work) return workAction[this.snapshot.work.kind];
229
+ if (!this.hasNativeWork && this.context?.nativeActivity === 'tool_running') return 'working';
230
+ if (!this.hasNativeWork && this.context?.nativeActivity === 'reasoning') return 'staged_thought';
231
+ return null;
232
+ }
233
+ play(action, force = false) {
234
+ if (this.action === action && !force) return;
235
+ this.cancelFrame();
236
+ this.action = action;
237
+ this.frame = 0;
238
+ this.paint();
239
+ this.scheduleFrame();
240
+ }
241
+ paint() {
242
+ const { style, dataset } = this.element;
243
+ dataset.skin = this.skin;
244
+ dataset.action = this.action;
245
+ dataset.dockEdge = this.dockedEdge ?? 'none';
246
+ dataset.dockZone = this.dockZone ?? 'none';
247
+ if (this.living()) {
248
+ style.backgroundImage = 'none';
249
+ this.livingBody.show(this.action, { reducedMotion: this.reducedMotion, onEnded: () => this.completeOneShot() });
250
+ return;
251
+ }
252
+ this.livingBody?.hide();
253
+ const staticFile = staticSkins[this.skin];
254
+ if (staticFile) {
255
+ style.backgroundImage = `url("./skins/${staticFile}")`;
256
+ style.backgroundSize = 'contain';
257
+ style.backgroundPosition = 'center';
258
+ return;
259
+ }
260
+ const visual = visualAlias[this.action] ?? this.action;
261
+ const extra = this.skin === 'xianxian-codex' ? additional[visual] : undefined;
262
+ style.backgroundImage = `url("./skins/${extra?.file ?? `${this.skin}.webp`}")`;
263
+ style.backgroundSize = extra ? '960px 130px' : '960px 1170px';
264
+ style.backgroundPosition = `${-120 * this.frame}px ${-130 * (atlas[visual]?.row ?? 0)}px`;
265
+ }
266
+ scheduleFrame() {
267
+ if (this.reducedMotion) {
268
+ if (this.oneShot) this.frameTimer = this.setTimer(() => { this.frameTimer = undefined; this.completeOneShot(); }, 1_000);
269
+ return;
270
+ }
271
+ if (staticSkins[this.skin]) return;
272
+ if (this.living()) {
273
+ if (isLivingClip(this.action) || !this.oneShot) return;
274
+ this.frameTimer = this.setTimer(() => { this.frameTimer = undefined; this.completeOneShot(); }, 700);
275
+ return;
276
+ }
277
+ const visual = visualAlias[this.action] ?? this.action;
278
+ const frames = additional[visual]?.frames ?? atlas[visual]?.frames;
279
+ if (!frames) return;
280
+ this.frameTimer = this.setTimer(() => {
281
+ this.frameTimer = undefined;
282
+ if (this.frame + 1 === frames.length && this.oneShot) {
283
+ this.completeOneShot();
284
+ return;
285
+ }
286
+ this.frame = (this.frame + 1) % frames.length;
287
+ this.paint();
288
+ this.scheduleFrame();
289
+ }, frames[this.frame]);
290
+ }
291
+ cancelFrame() {
292
+ if (this.frameTimer !== undefined) this.clearTimer(this.frameTimer);
293
+ this.frameTimer = undefined;
294
+ }
295
+ completeOneShot() {
296
+ if (!this.oneShot) return;
297
+ this.oneShot = false;
298
+ this.transient = null;
299
+ this.action = '';
300
+ this.refresh();
301
+ }
302
+ canPlay() {
303
+ return !this.reducedMotion && !this.dragging && !this.pendingDecision && this.phase === 'idle'
304
+ && !this.activeWorkAction() && !this.snapshot.delivery && !this.snapshot.travel && !this.dockedEdge
305
+ && !staticSkins[this.skin];
306
+ }
307
+
308
+ armRest() {
309
+ if (this.restTimer === undefined && !this.resting && !this.pendingDecision && !this.dockedEdge) {
310
+ this.restTimer = this.setTimer(() => {
311
+ this.restTimer = undefined;
312
+ if (this.action === 'idle' && !this.dragging && !this.transient) {
313
+ this.resting = true;
314
+ this.refresh();
315
+ }
316
+ }, 45_000);
317
+ }
318
+ if (this.autoPlay && !this.reducedMotion && this.autoTimer === undefined) {
319
+ const delay = Math.max(this.autoPlayIdleMs, this.lastPlayAt + this.autoPlayCooldownMs - this.now());
320
+ this.autoTimer = this.setTimer(() => {
321
+ this.autoTimer = undefined;
322
+ if (!this.canPlay()) return;
323
+ this.lastPlayAt = this.now();
324
+ this.resting = false;
325
+ this.transient = { action: 'play', automatic: true };
326
+ this.refresh();
327
+ }, delay);
328
+ }
329
+ }
330
+
331
+ cancelRest() {
332
+ if (this.restTimer !== undefined) this.clearTimer(this.restTimer);
333
+ this.restTimer = undefined;
334
+ }
335
+
336
+ cancelIdleTimers() {
337
+ this.cancelRest();
338
+ if (this.autoTimer !== undefined) this.clearTimer(this.autoTimer);
339
+ this.autoTimer = undefined;
340
+ }
341
+
342
+ get snapshot() { return this.facts.snapshot; }
343
+ }