@clowder-ai/companion 0.1.0-alpha.3 → 0.1.0-alpha.31

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 (70) hide show
  1. package/NOTICE +7 -2
  2. package/README.md +54 -5
  3. package/manifest.json +3 -3
  4. package/package.json +5 -3
  5. package/plugin.yaml +3 -3
  6. package/renderer/app-assist-controller.mjs +125 -0
  7. package/renderer/call-controls-presentation.mjs +179 -0
  8. package/renderer/call-duration.mjs +59 -0
  9. package/renderer/call-transcript-view.mjs +240 -0
  10. package/renderer/chat-unread.mjs +44 -0
  11. package/renderer/client.mjs +75 -3
  12. package/renderer/compact-size.mjs +8 -0
  13. package/renderer/companion-identity.mjs +42 -0
  14. package/renderer/conversation-draft.mjs +304 -0
  15. package/renderer/conversation-history.mjs +176 -0
  16. package/renderer/conversation-surface.mjs +201 -0
  17. package/renderer/conversation.mjs +155 -21
  18. package/renderer/decision-panel.mjs +122 -0
  19. package/renderer/decision-view.mjs +124 -0
  20. package/renderer/errors.mjs +4 -4
  21. package/renderer/index.html +202 -25
  22. package/renderer/living-body.mjs +147 -0
  23. package/renderer/living-edge.mjs +16 -0
  24. package/renderer/living-geometry.mjs +96 -0
  25. package/renderer/meeting-state.mjs +55 -0
  26. package/renderer/motion-facts.mjs +108 -0
  27. package/renderer/native-window-travel.mjs +89 -0
  28. package/renderer/native-work-motion.mjs +39 -0
  29. package/renderer/peer.mjs +1 -1
  30. package/renderer/pet-controls.mjs +139 -27
  31. package/renderer/pet-motion-assets.mjs +22 -0
  32. package/renderer/pet-motion.mjs +343 -0
  33. package/renderer/recent-bubble.mjs +93 -0
  34. package/renderer/reply-letter-controller.mjs +361 -0
  35. package/renderer/reply-letter-state.mjs +38 -0
  36. package/renderer/reply-letter-view.mjs +116 -0
  37. package/renderer/settings-controller.mjs +171 -0
  38. package/renderer/settings-view.mjs +241 -0
  39. package/renderer/skins/xianxian-codex.webp +0 -0
  40. package/renderer/skins/xianxian-living/clips/carry.webm +0 -0
  41. package/renderer/skins/xianxian-living/clips/peek.webm +0 -0
  42. package/renderer/skins/xianxian-living/clips/peek_fade.webm +0 -0
  43. package/renderer/skins/xianxian-living/clips/pounce.webm +0 -0
  44. package/renderer/skins/xianxian-living/clips/sleep.webm +0 -0
  45. package/renderer/skins/xianxian-living/clips/think.webm +0 -0
  46. package/renderer/skins/xianxian-living/clips/trot.webm +0 -0
  47. package/renderer/skins/xianxian-living/clips/wait.webm +0 -0
  48. package/renderer/skins/xianxian-living/clips/wake.webm +0 -0
  49. package/renderer/skins/xianxian-living/layers/body.webp +0 -0
  50. package/renderer/skins/xianxian-living/layers/closed.webp +0 -0
  51. package/renderer/skins/xianxian-living/layers/earL.webp +0 -0
  52. package/renderer/skins/xianxian-living/layers/earR.webp +0 -0
  53. package/renderer/skins/xianxian-living/layers/head.webp +0 -0
  54. package/renderer/skins/xianxian-living/layers/irisL.webp +0 -0
  55. package/renderer/skins/xianxian-living/layers/irisR.webp +0 -0
  56. package/renderer/skins/xianxian-living/layers/meta.json +88 -0
  57. package/renderer/skins/xianxian-living/layers/mouth.webp +0 -0
  58. package/renderer/skins/xianxian-living/layers/scared.webp +0 -0
  59. package/renderer/skins/xianxian-living/layers/tail.webp +0 -0
  60. package/renderer/skins/xianxian-living/layers/whiteL.webp +0 -0
  61. package/renderer/skins/xianxian-living/layers/whiteR.webp +0 -0
  62. package/renderer/skins/xianxian-sleeping-row.png +0 -0
  63. package/renderer/skins/xianxian-staged-thought-row.png +0 -0
  64. package/renderer/skins/xianxian-working-row.png +0 -0
  65. package/renderer/style.css +325 -27
  66. package/renderer/surface.mjs +308 -51
  67. package/renderer/transcript-view.mjs +117 -15
  68. package/renderer/visible-text-receipt.mjs +241 -0
  69. package/renderer/work-overview.mjs +95 -0
  70. package/source-lock.json +122 -2
@@ -0,0 +1,108 @@
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
+ const normalizeTravel = (travel, now, suppressed) => travel && validId(travel.eventId)
5
+ && travel.status === 'active' && Number.isFinite(travel.dx) && Number.isFinite(travel.dy)
6
+ && Math.hypot(travel.dx, travel.dy) >= 1 && validExpiry(travel.expiresAt, now)
7
+ && !suppressed.has(travel.eventId)
8
+ ? { eventId: travel.eventId, dx: travel.dx, dy: travel.dy, expiresAt: travel.expiresAt } : null;
9
+
10
+ /** A bounded, expiring projection of Host-owned motion facts. */
11
+ export class MotionFacts {
12
+ constructor({ now, setTimer, clearTimer, changed }) {
13
+ this.now = now;
14
+ this.setTimer = setTimer;
15
+ this.clearTimer = clearTimer;
16
+ this.changed = changed;
17
+ this.snapshot = { work: null, delivery: null, travel: null };
18
+ this.seenResults = new Map();
19
+ this.suppressedTravel = new Map();
20
+ }
21
+
22
+ sync(value, suppressTravel = false) {
23
+ const now = this.now();
24
+ this.prune(now);
25
+ const input = value && typeof value === 'object' ? value : {};
26
+ const work = input.work;
27
+ const delivery = input.delivery;
28
+ const travel = input.travel;
29
+ this.snapshot = {
30
+ work: work && validId(work.taskId) && work.status === 'active' && workKinds.has(work.kind)
31
+ && validExpiry(work.expiresAt, now)
32
+ ? { taskId: work.taskId, kind: work.kind, expiresAt: work.expiresAt } : null,
33
+ delivery: delivery && validId(delivery.resultId) && delivery.status === 'applied'
34
+ && validExpiry(delivery.expiresAt, now)
35
+ ? { resultId: delivery.resultId, expiresAt: delivery.expiresAt } : null,
36
+ travel: normalizeTravel(travel, now, this.suppressedTravel),
37
+ };
38
+ if (suppressTravel) this.suppressTravel();
39
+ this.scheduleExpiry();
40
+ }
41
+
42
+ syncNative(value) {
43
+ const now = this.now();
44
+ this.prune(now);
45
+ const input = value && typeof value === 'object' ? value : {};
46
+ const work = input.work;
47
+ const delivery = input.delivery;
48
+ const travel = this.snapshot.travel?.expiresAt > now ? this.snapshot.travel : null;
49
+ this.snapshot = {
50
+ work: work && validId(work.taskId) && work.status === 'active' && workKinds.has(work.kind)
51
+ && validExpiry(work.expiresAt, now)
52
+ ? { taskId: work.taskId, kind: work.kind, expiresAt: work.expiresAt } : null,
53
+ delivery: delivery && validId(delivery.resultId) && delivery.status === 'applied'
54
+ && validExpiry(delivery.expiresAt, now)
55
+ ? { resultId: delivery.resultId, expiresAt: delivery.expiresAt } : null,
56
+ travel,
57
+ };
58
+ this.scheduleExpiry();
59
+ }
60
+
61
+ syncTravel(value, suppressTravel = false) {
62
+ const now = this.now();
63
+ this.prune(now);
64
+ this.snapshot.travel = normalizeTravel(value, now, this.suppressedTravel);
65
+ if (suppressTravel) this.suppressTravel();
66
+ this.scheduleExpiry();
67
+ }
68
+
69
+ consumeDelivery() {
70
+ const delivery = this.snapshot.delivery;
71
+ if (!delivery || this.seenResults.has(delivery.resultId)) return null;
72
+ this.seenResults.set(delivery.resultId, this.now() + 3_600_000);
73
+ return delivery;
74
+ }
75
+
76
+ suppressTravel() {
77
+ if (!this.snapshot.travel) return;
78
+ this.suppressedTravel.set(this.snapshot.travel.eventId, this.now() + 3_600_000);
79
+ this.snapshot.travel = null;
80
+ this.scheduleExpiry();
81
+ }
82
+
83
+ close() {
84
+ if (this.timer !== undefined) this.clearTimer(this.timer);
85
+ this.timer = undefined;
86
+ }
87
+
88
+ scheduleExpiry() {
89
+ this.close();
90
+ const expiries = Object.values(this.snapshot).filter(Boolean).map(value => value.expiresAt);
91
+ if (!expiries.length) return;
92
+ this.timer = this.setTimer(() => {
93
+ this.timer = undefined;
94
+ const now = this.now();
95
+ for (const key of ['work', 'delivery', 'travel']) {
96
+ if (this.snapshot[key]?.expiresAt <= now) this.snapshot[key] = null;
97
+ }
98
+ this.scheduleExpiry();
99
+ this.changed();
100
+ }, Math.max(0, Math.min(...expiries) - this.now()));
101
+ }
102
+
103
+ prune(now) {
104
+ for (const collection of [this.seenResults, this.suppressedTravel]) {
105
+ for (const [id, expiresAt] of collection) if (expiresAt <= now) collection.delete(id);
106
+ }
107
+ }
108
+ }
@@ -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' }); }
@@ -1,29 +1,113 @@
1
1
  /** Transient controls. The Host owns native placement, movement and hiding. */
2
- export function bindPetControls(client, { action, error, changed }) {
2
+ export function bindPetControls(client, {
3
+ action, error, changed, moved = () => {}, activate = () => false, escape = () => false,
4
+ }) {
3
5
  const $ = id => document.getElementById(id);
4
6
  const body = $('pet');
5
- let panel = 'none', layoutRevision = 0, drag, suppressClick = false;
7
+ const panelIds = ['actions', 'menu', 'chat', 'bubble', 'app-assist', 'decisions', 'transcript', 'settings'];
8
+ const callBubble = $('call-bubble');
9
+ const managedIds = [...panelIds, 'call-bubble'];
10
+ const desiredSizes = new WeakMap();
11
+ let panel = 'none', ambientPanel = 'none', layoutRevision = 0, layoutScheduled = false, drag, suppressClick = false;
12
+ const measure = node => {
13
+ if (!node) return { width: 120, height: 130 };
14
+ // Host placement is an applied cap, not the panel's desired geometry.
15
+ // Release it synchronously while measuring so CSS height and nested flex
16
+ // overflow can expose their natural demand, then restore it before paint.
17
+ const appliedMaxHeight = node.style.maxHeight;
18
+ node.style.maxHeight = '';
19
+ const desired = {
20
+ width: Math.min(420, Math.max(120, Math.ceil(Math.max(node.offsetWidth, node.scrollWidth)))),
21
+ height: Math.min(500, Math.max(32, Math.ceil(Math.max(node.offsetHeight, node.scrollHeight)))),
22
+ };
23
+ node.style.maxHeight = appliedMaxHeight;
24
+ return desired;
25
+ };
26
+ const hasCallBubble = () => callBubble?.querySelector('p:not([hidden])') !== null;
27
+ const callLayout = next => {
28
+ if (next !== 'actions') {
29
+ if (callBubble) callBubble.hidden = true;
30
+ return undefined;
31
+ }
32
+ const presentation = $('actions').dataset.presentation === 'controls' ? 'controls' : 'lite';
33
+ const call = { presentation };
34
+ if (callBubble && hasCallBubble()) {
35
+ callBubble.hidden = false;
36
+ call.bubble = measure(callBubble);
37
+ } else if (callBubble) callBubble.hidden = true;
38
+ return call;
39
+ };
40
+ const sizeKey = (desired, call) => [
41
+ desired.width, desired.height, call?.presentation ?? '',
42
+ call?.bubble?.width ?? '', call?.bubble?.height ?? '',
43
+ ].join(':');
6
44
  async function show(next) {
7
- if (panel !== next && $(next)) $(next).style.maxHeight = '500px';
8
- panel = next;
9
- for (const id of ['actions', 'menu', 'chat']) $(id).hidden = id !== next;
10
- body.setAttribute('aria-expanded', String(next !== 'none'));
45
+ if (next === 'none' && ambientPanel !== 'none') next = ambientPanel;
46
+ const previousPanel = panel;
11
47
  const node = $(next);
48
+ panel = next;
49
+ for (const id of panelIds) $(id).hidden = id !== next;
50
+ body.setAttribute('aria-expanded', String(!['none', 'bubble'].includes(next)));
51
+ const call = callLayout(next);
52
+ const desired = measure(node);
53
+ if (node) desiredSizes.set(node, sizeKey(desired, call));
12
54
  const revision = ++layoutRevision;
13
55
  try {
14
- const placed = await client.layout(next, Math.max(120, Math.ceil(node?.offsetWidth ?? 120)), Math.max(32, Math.ceil(node?.offsetHeight ?? 130)));
56
+ const placed = await client.layout(next, desired.width, desired.height, call);
15
57
  if (revision !== layoutRevision) return;
16
58
  $('anchor').style.left = `${placed.pet.x}px`; $('anchor').style.top = `${placed.pet.y}px`;
17
59
  if (node) { node.style.left = `${placed.panel.x}px`; node.style.top = `${placed.panel.y}px`; node.style.maxHeight = `${placed.panel.height}px`; }
18
- if (next === 'chat') $('message').focus({ preventScroll: true });
19
- if (next === 'menu') $('menu').querySelector('button').focus({ preventScroll: true });
20
- changed(next);
21
- } catch (cause) { error(cause); }
60
+ if (callBubble) {
61
+ const visible = next === 'actions' && call?.bubble && placed.bubble;
62
+ callBubble.hidden = !visible;
63
+ if (visible) {
64
+ callBubble.style.left = `${placed.bubble.x}px`;
65
+ callBubble.style.top = `${placed.bubble.y}px`;
66
+ callBubble.style.maxHeight = `${placed.bubble.height}px`;
67
+ }
68
+ }
69
+ if (previousPanel !== next) {
70
+ if (next === 'chat') {
71
+ const mode = $('chat').dataset.replyMode;
72
+ const target = mode === 'reply'
73
+ ? ($('reply-close')?.hidden === false ? $('reply-close') : $('reply-retain'))
74
+ : mode === 'sent' ? $('reply-sent') : $('message');
75
+ target?.focus?.({ preventScroll: true });
76
+ }
77
+ if (next === 'transcript') $('call-message').focus({ preventScroll: true });
78
+ if (next === 'settings') $('settings-back').focus({ preventScroll: true });
79
+ if (next === 'decisions') $('decision-reload').focus({ preventScroll: true });
80
+ if (next === 'menu') $('menu').querySelector('button').focus({ preventScroll: true });
81
+ changed(next);
82
+ }
83
+ } catch (cause) {
84
+ if (revision !== layoutRevision) return;
85
+ panel = previousPanel;
86
+ for (const id of panelIds) $(id).hidden = id !== panel;
87
+ if (callBubble) callBubble.hidden = panel !== 'actions' || !hasCallBubble();
88
+ body.setAttribute('aria-expanded', String(!['none', 'bubble'].includes(panel)));
89
+ error(cause, { panel: next });
90
+ }
91
+ }
92
+ function remeasure() {
93
+ if (layoutScheduled) return;
94
+ layoutScheduled = true;
95
+ queueMicrotask(() => {
96
+ layoutScheduled = false;
97
+ const node = $(panel);
98
+ if (!node || node.hidden) return;
99
+ const call = callLayout(panel);
100
+ const desired = measure(node);
101
+ const key = sizeKey(desired, call);
102
+ if (desiredSizes.get(node) === key) return;
103
+ void show(panel);
104
+ });
22
105
  }
23
106
  body.onclick = event => {
24
107
  if (suppressClick) { suppressClick = false; return; }
25
108
  if (event.detail > 1) return;
26
- void show(panel === 'actions' ? 'none' : 'actions');
109
+ if (activate('click')) return;
110
+ void show(panel === 'menu' ? 'none' : 'menu');
27
111
  };
28
112
  body.oncontextmenu = event => { event.preventDefault(); void show('menu'); };
29
113
  body.onkeydown = event => {
@@ -31,37 +115,65 @@ export function bindPetControls(client, { action, error, changed }) {
31
115
  };
32
116
  body.onpointerdown = event => {
33
117
  if (event.button !== 0) return;
34
- drag = { id: event.pointerId, x: event.screenX, y: event.screenY };
118
+ const current = { id: event.pointerId, x: event.screenX, y: event.screenY, armed: false };
119
+ drag = current;
35
120
  body.setPointerCapture(event.pointerId);
36
- void client.drag('start').catch(error);
121
+ void client.drag('start').then(() => { if (drag === current) current.armed = true; }).catch(error);
37
122
  };
38
123
  body.onpointermove = event => {
39
- if (drag && Math.hypot(event.screenX - drag.x, event.screenY - drag.y) >= 6) suppressClick = true;
124
+ if (!drag) return;
125
+ const dx = event.screenX - drag.x, dy = event.screenY - drag.y;
126
+ if (Math.hypot(dx, dy) < 6) return;
127
+ suppressClick = true;
128
+ if (drag.armed) moved(dx, dy);
129
+ drag.x = event.screenX; drag.y = event.screenY;
40
130
  };
41
131
  for (const name of ['pointerup', 'pointercancel']) body.addEventListener(name, () => {
42
- drag = undefined; void client.drag('end').catch(error);
132
+ drag = undefined; moved('stop'); void client.drag('end').catch(error);
43
133
  });
44
134
  document.addEventListener('pointerdown', event => {
45
135
  if (!event.target.closest('#anchor, .floating')) void show('none');
46
136
  });
47
137
  document.addEventListener('keydown', event => {
48
- if (event.key === 'Escape' && panel !== 'none') { event.preventDefault(); void show('none'); body.focus(); }
138
+ if (event.key === 'Escape' && panel !== 'none') {
139
+ event.preventDefault();
140
+ if (!escape(panel)) void show('none');
141
+ body.focus();
142
+ }
49
143
  });
50
144
  document.querySelectorAll('[data-action]').forEach(button => button.onclick = () => {
51
145
  const kind = button.dataset.action;
52
- if (kind === 'write' || kind === 'history') { void show('chat'); return; }
146
+ if (kind === 'write' || kind === 'history') { action(kind); void show('chat'); return; }
147
+ if (kind === 'transcript') { void show('transcript'); return; }
148
+ if (kind === 'settings') { void show('settings'); return; }
149
+ if (kind === 'decisions') { void show('decisions'); return; }
53
150
  if (kind === 'dismiss') { void show('none'); return; }
54
151
  action(kind);
55
152
  });
56
- const sizes = new WeakMap();
57
- const observer = new ResizeObserver(entries => {
58
- for (const entry of entries) {
59
- const size = `${entry.contentRect.width}:${entry.contentRect.height}`;
60
- const previous = sizes.get(entry.target); sizes.set(entry.target, size);
61
- if (previous && previous !== size && entry.target.id === panel && !entry.target.hidden) void show(panel);
62
- }
153
+ const observer = new MutationObserver(records => {
154
+ const current = $(panel);
155
+ if (!current || current.hidden) return;
156
+ // Root visibility is already handled by show(). Descendant visibility and
157
+ // text/row mutations are content demand and may require a larger surface.
158
+ if (records.some(record => record.type !== 'attributes' || !managedIds.includes(record.target.id))) remeasure();
159
+ });
160
+ for (const id of panelIds) observer.observe($(id), {
161
+ subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['hidden'],
162
+ });
163
+ if (callBubble) observer.observe(callBubble, {
164
+ subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['hidden'],
63
165
  });
64
- for (const id of ['actions', 'menu', 'chat']) observer.observe($(id));
65
166
  window.addEventListener('beforeunload', () => observer.disconnect());
66
- return { get panel() { return panel; }, show, dismiss() { if (drag) suppressClick = true; void show('none'); } };
167
+ return {
168
+ get panel() { return panel; }, show, remeasure,
169
+ setAmbient(value) {
170
+ const next = value === true ? 'bubble' : value === false ? 'none' : value;
171
+ const ambientPanels = ['none', 'bubble', 'actions', 'app-assist'];
172
+ if (!ambientPanels.includes(next)) return Promise.resolve();
173
+ if (ambientPanel === next) return Promise.resolve();
174
+ ambientPanel = next;
175
+ return ambientPanels.includes(panel) ? show('none') : Promise.resolve();
176
+ },
177
+ dismiss() { if (drag) suppressClick = true; void show('none'); },
178
+ };
67
179
  }
@@ -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;