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

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.
@@ -18,12 +18,14 @@ import { currentCompanionIdentity } from './companion-identity.mjs';
18
18
  import { FoldedChatUnread } from './chat-unread.mjs';
19
19
  import { COMPACT_BALL_SIZE, compactSizeAction } from './compact-size.mjs';
20
20
  import { WorkOverviewView } from './work-overview.mjs';
21
+ import { MeetingStatus, renderMeetingStatus } from './meeting-state.mjs';
22
+ import { VisibleTextPresentation } from './visible-text-receipt.mjs';
23
+ import { ConversationSurface } from './conversation-surface.mjs';
21
24
  const $ = id => document.getElementById(id);
22
25
  const label = (id, text) => { $(id).querySelector('.label').textContent = text; };
23
26
  const status = text => {
24
27
  $('status').textContent = text;
25
28
  $('menu-status').textContent = text;
26
- $('chat-status').textContent = text;
27
29
  $('announcement').textContent = text;
28
30
  };
29
31
  const transcript = new TranscriptView($('chat-transcript'));
@@ -35,6 +37,7 @@ const callTranscript = new CallTranscriptView($('call-transcript-log'), {
35
37
  });
36
38
  const bubble = new RecentBubble([$('bubble-first'), $('bubble-second')]);
37
39
  const callBubble = new RecentBubble([$('call-bubble-first'), $('call-bubble-second')]);
40
+ const meetingStatus = new MeetingStatus();
38
41
  const foldedChatUnread = new FoldedChatUnread(count => {
39
42
  $('call-chat-unread').hidden = count === 0;
40
43
  $('call-chat-unread').textContent = count ? String(count) : '';
@@ -46,8 +49,8 @@ const nativeTravel = new NativeWindowTravel({
46
49
  readPosition: () => ({ x: window.screenX, y: window.screenY }),
47
50
  changed: value => motion.syncTravel(value),
48
51
  });
49
- let sharing = false, pendingScreen = false, loading = false, transcriptLoading = false, latestHistory, previousPhase = 'idle';
50
- let didMove = false, threadTitle, settingsView, settingsValues, decisionPanel, workView;
52
+ let sharing = false, pendingScreen = false, transcriptLoading = false, previousPhase = 'idle';
53
+ let didMove = false, settingsView, settingsValues, decisionPanel, workView;
51
54
  let previousControlPanel = 'none';
52
55
  // F229 stays quiet: a history ID change has no provenance for proactive text.
53
56
  const ambientPanel = active => active ? 'actions' : 'none';
@@ -60,6 +63,12 @@ if (!window.clowderCompanion) {
60
63
  } else {
61
64
  nativeTravel.start();
62
65
  const client = createCompanionClient(window.clowderCompanion);
66
+ let conversationSurface;
67
+ const visibleText = new VisibleTextPresentation({
68
+ document,
69
+ report: observation => client.observePresentation(observation),
70
+ transcript, callTranscript, bubble, callBubble,
71
+ });
63
72
  const controls = bindPetControls(client, {
64
73
  error: (error, context) => status(context?.panel === 'settings' && error?.code === 'invalid_request' ? '这个 Host 版本还不支持猫旁设置。原来的设置入口会继续保留。' : explainError(error)),
65
74
  moved: (dx, dy) => {
@@ -72,16 +81,23 @@ if (!window.clowderCompanion) {
72
81
  setTimeout(() => motion.setDockedEdge(detectDockedEdge(window, $('pet').getBoundingClientRect())), 100);
73
82
  },
74
83
  changed: panel => {
84
+ visibleText.retry();
85
+ if (previousControlPanel === 'chat' && panel !== 'chat') conversationSurface.closeHistory();
86
+ if (panel === 'chat' && conversationSurface.controller.mode === 'idle') conversationSurface.openHistory();
75
87
  if (panel === 'chat') foldedChatUnread.open();
76
88
  else if (previousControlPanel === 'chat' && conversation?.active) foldedChatUnread.fold();
77
89
  previousControlPanel = panel;
78
- if (panel === 'chat') void readHistory();
90
+ if (panel === 'chat') void conversationSurface.readHistory();
79
91
  if (panel === 'transcript') void readCallTranscript();
80
92
  if (panel === 'decisions') void decisionPanel?.read();
81
93
  if (panel === 'settings') void settingsView?.open();
82
94
  if (panel === 'menu') void workView?.load();
83
95
  },
84
96
  action(kind) {
97
+ if (kind === 'write') {
98
+ conversationSurface.openWrite(conversation.active);
99
+ }
100
+ if (kind === 'history') conversationSurface.openHistory();
85
101
  if (kind === 'begin') { transcript.reset(); callTranscript.reset(); bubble.reset(); callBubble.clear(); void conversation.begin(); void controls.show('none'); }
86
102
  if (kind === 'listen') { transcript.reset(); callTranscript.reset(); bubble.reset(); callBubble.clear(); void conversation.begin('receive_only'); void controls.show('none'); }
87
103
  if (kind === 'stop') void conversation.end();
@@ -137,18 +153,17 @@ if (!window.clowderCompanion) {
137
153
  }
138
154
  const conversation = new CompanionConversation({
139
155
  client, createPeer: callback => new VoicePeer(callback, client), stopScreen: () => screen.stop(),
156
+ delivery: ({ text, receipt }) => conversationSurface.controller.recordDelivery(receipt, text),
140
157
  transcript: event => {
141
158
  if (event.type === 'turn-done') {
142
159
  transcript.finish(event); callTranscript.finish(event); bubble.finish(event.role); callBubble.finish(event.role);
143
- void readHistory(); void readCallTranscript();
160
+ void conversationSurface.readHistory(); void readCallTranscript();
144
161
  } else if (event.type === 'typed') {
145
162
  callTranscript.appendTyped(event);
146
- void readHistory(); void readCallTranscript();
163
+ void conversationSurface.readHistory(); void readCallTranscript();
147
164
  } else {
148
- transcript.append(event.role, event.text, true);
149
- callTranscript.appendLive(event);
150
165
  const speaker = event.role === 'assistant' ? conversation.identity?.displayName ?? '猫猫' : '语音';
151
- bubble.append(event.role, event.text, speaker); callBubble.append(event.role, event.text, speaker);
166
+ visibleText.appendLive(event, speaker);
152
167
  void controls.setAmbient('actions');
153
168
  }
154
169
  },
@@ -156,7 +171,7 @@ if (!window.clowderCompanion) {
156
171
  const showFailure = value.failed && previousPhase !== 'idle' && value.phase === 'idle';
157
172
  const justConnected = previousPhase !== 'talking' && value.phase === 'talking';
158
173
  if (previousPhase !== 'idle' && value.phase === 'idle') {
159
- transcript.reset(); callTranscript.reset(); bubble.reset(); callBubble.clear(); void readHistory();
174
+ transcript.reset(); callTranscript.reset(); bubble.reset(); callBubble.clear(); void conversationSurface.readHistory();
160
175
  foldedChatUnread.reset();
161
176
  if (controls.panel === 'transcript') void controls.show('menu');
162
177
  }
@@ -165,7 +180,7 @@ if (!window.clowderCompanion) {
165
180
  const receiveOnly = value.audioMode === 'receive_only';
166
181
  const canReceiveOnly = value.identity?.audio?.supportedModes?.includes('receive_only') === true;
167
182
  const retryReceiveOnly = value.failed && value.retryMode === 'receive_only' && canReceiveOnly;
168
- callTranscript.setCall(value.callId);
183
+ visibleText.setCall(value.callId);
169
184
  void controls.setAmbient(ambientPanel(active));
170
185
  $('begin').hidden = active; $('begin').disabled = !value.identity;
171
186
  $('listen').hidden = active || !canReceiveOnly; $('listen').disabled = !value.identity;
@@ -191,7 +206,8 @@ if (!window.clowderCompanion) {
191
206
  : value.phase === 'connecting' ? '正在请求麦克风'
192
207
  : value.recovering ? `正在等待原连接恢复 · ${value.muted ? '麦克风已静音' : '麦克风已开启'}`
193
208
  : value.muted ? '麦克风已静音' : active ? '麦克风已开启' : '';
194
- for (const id of ['actions', 'chat-call-context', 'transcript-call-context', 'menu-call-context', 'settings-call-context']) $(id).dataset.connectionState = value.recovering ? 'recovering' : active ? 'connected' : 'idle';
209
+ const connectionState = value.recovering ? 'recovering' : value.phase === 'talking' ? 'connected' : value.phase === 'connecting' ? 'connecting' : 'idle';
210
+ for (const id of ['actions', 'chat-call-context', 'transcript-call-context', 'menu-call-context', 'settings-call-context']) $(id).dataset.connectionState = connectionState;
195
211
  setTexts(['call-state', 'chat-call-state', 'transcript-call-state', 'menu-call-state', 'settings-call-state'], callState);
196
212
  setTexts(['call-mic-state', 'chat-call-mic-state', 'transcript-call-mic-state', 'menu-call-mic-state', 'settings-call-mic-state'], microphoneState);
197
213
  setHidden(['chat-call-context', 'transcript-call-context', 'menu-call-context', 'settings-call-context'], !active);
@@ -214,6 +230,7 @@ if (!window.clowderCompanion) {
214
230
  $('menu-hide-label').textContent = active ? '结束通话并隐藏' : '隐藏';
215
231
  const identity = value.identity;
216
232
  const companionIdentity = currentCompanionIdentity(identity);
233
+ renderMeetingStatus(document, meetingStatus.update(active ? identity?.meeting : null));
217
234
  const nativeSnapshot = normalizeNativeWork(identity?.nativeWork);
218
235
  const workLabel = nativeWorkLabel(nativeSnapshot, identity?.nativeActivity);
219
236
  const workState = workLabel ? `${identity.displayName}${workLabel}` : '';
@@ -229,7 +246,8 @@ if (!window.clowderCompanion) {
229
246
  $('menu-play').hidden = identity.skin !== 'xianxian-codex';
230
247
  const companionLabel = companionIdentity
231
248
  ? `${companionIdentity.title} · ${companionIdentity.partnerLabel}` : identity.displayName;
232
- $('chat-name').textContent = threadTitle ?? companionLabel; $('menu-name').textContent = companionLabel;
249
+ $('chat-name').textContent = conversationSurface?.threadTitle ?? companionLabel; $('menu-name').textContent = companionLabel;
250
+ $('message').placeholder = `发消息给${identity.displayName}…`;
233
251
  $('call-message').placeholder = `发消息给${identity.displayName}…`;
234
252
  $('pet').setAttribute('aria-label', `${companionLabel}:点击或右键打开名片,拖动移动`);
235
253
  $('documents').querySelector('.menu-status').textContent = identity.documentsAllowed ? '开启' : '暂停';
@@ -247,6 +265,15 @@ if (!window.clowderCompanion) {
247
265
  if (showFailure) void controls.show('menu');
248
266
  },
249
267
  });
268
+ conversationSurface = new ConversationSurface({
269
+ document, client, controls, bubble, transcript,
270
+ getCallPhase: () => conversation.phase,
271
+ onAccepted: () => motion.signal('received'),
272
+ retryLegacy: text => conversation.send(text),
273
+ status,
274
+ scheduleInterval: setInterval,
275
+ cancelInterval: clearInterval,
276
+ });
250
277
  settingsView = new SettingsView({
251
278
  document,
252
279
  client,
@@ -280,21 +307,6 @@ if (!window.clowderCompanion) {
280
307
  motion,
281
308
  proactivePolicy: () => settingsValues?.proactivePolicy,
282
309
  });
283
- async function readHistory() {
284
- if (loading) return;
285
- loading = true;
286
- try {
287
- const history = await client.readConversation();
288
- $('history').textContent = history.hasMore ? '在 Cat Café 中查看更早聊天 ↗' : '在 Cat Café 中查看完整聊天 ↗';
289
- threadTitle = history.threadTitle;
290
- $('chat-name').textContent = threadTitle;
291
- bubble.load(history.messages);
292
- void controls.setAmbient(ambientPanel(conversation.phase !== 'idle'));
293
- const serialized = JSON.stringify(history.messages);
294
- if (serialized !== latestHistory) { latestHistory = serialized; transcript.load(history.messages); }
295
- } catch { $('chat-status').textContent = '聊天记录暂未更新 · 正在说的话仍会显示'; }
296
- finally { loading = false; }
297
- }
298
310
  async function readCallTranscript() {
299
311
  if (transcriptLoading || !conversation.active || !conversation.callId) return;
300
312
  transcriptLoading = true;
@@ -302,41 +314,36 @@ if (!window.clowderCompanion) {
302
314
  try {
303
315
  const reply = await client.readTranscript();
304
316
  if (!conversation.active || conversation.callId !== callId) return;
305
- callTranscript.load(reply);
317
+ if (!visibleText.loadTranscript(callId, reply, conversation.identity?.displayName)) return;
306
318
  foldedChatUnread.receiveMany(reply.rows);
307
- callBubble.loadTranscript(reply.rows, conversation.identity?.displayName);
308
319
  $('call-transcript-status').textContent = reply.hasMore ? '这里只显示这次通话最近的内容' : '';
309
320
  } catch {
310
321
  if (conversation.active && conversation.callId === callId) $('call-transcript-status').textContent = '字幕暂未更新 · 实时内容仍会继续显示';
311
322
  } finally { transcriptLoading = false; }
312
323
  }
313
324
  $('share-badge').onclick = toggleScreen;
314
- $('history').onclick = () => void client.openConversation().then(result => {
315
- if (result.delivery === 'unconfirmed') status('打开聊天尚未确认 · 请从 Clowder 查看');
316
- }).catch(error => status(explainError(error)));
317
- async function submitMessage(event, inputId) {
318
- event.preventDefault(); const input = $(inputId); const text = input.value;
325
+ async function submitCallMessage(event) {
326
+ event.preventDefault(); const input = $('call-message'); const text = input.value;
319
327
  if (await conversation.send(text)) {
320
328
  motion.signal('received');
321
329
  if (input.value === text) input.value = '';
322
- latestHistory = undefined; void readHistory();
330
+ void conversationSurface.readHistory();
323
331
  }
324
332
  }
325
- $('compose').onsubmit = event => submitMessage(event, 'message');
326
- $('call-compose').onsubmit = event => submitMessage(event, 'call-message');
333
+ $('call-compose').onsubmit = submitCallMessage;
327
334
  $('call-unread').onclick = () => callTranscript.scrollToBottom();
328
335
  const unsubscribe = client.subscribe(event => {
329
336
  if (event.kind === 'media-stopped') void conversation.hostStopped(event.reason);
330
337
  if (event.kind === 'view-dismiss') controls.dismiss();
331
338
  });
332
339
  const statusMonitor = setInterval(() => void conversation.refresh(), 1000);
333
- const historyMonitor = setInterval(() => void readHistory(), 3000);
334
340
  const transcriptMonitor = setInterval(() => void readCallTranscript(), 1000);
335
341
  const decisionMonitor = setInterval(() => { if (controls.panel !== 'decisions') void decisionPanel.read(); }, 15000);
336
342
  const workMonitor = setInterval(() => { if (controls.panel === 'menu') void workView.load(); }, 15000);
337
- void conversation.refresh(); void readHistory(); void decisionPanel.read(); void settingsView.controller.load(); void controls.show('none');
343
+ void conversation.refresh(); conversationSurface.start(); void decisionPanel.read(); void settingsView.controller.load(); void controls.show('none');
338
344
  window.addEventListener('beforeunload', () => {
339
- clearInterval(statusMonitor); clearInterval(historyMonitor); clearInterval(transcriptMonitor); clearInterval(decisionMonitor); clearInterval(workMonitor);
340
- unsubscribe(); nativeTravel.close(); motion.close(); void conversation.end();
345
+ clearInterval(statusMonitor); clearInterval(transcriptMonitor); clearInterval(decisionMonitor); clearInterval(workMonitor);
346
+ conversationSurface.close();
347
+ unsubscribe(); visibleText.close(); nativeTravel.close(); motion.close(); void conversation.end();
341
348
  });
342
349
  }
@@ -20,6 +20,7 @@ export class TranscriptView {
20
20
  }
21
21
  row.textContent = (row.textContent + text).slice(-16000);
22
22
  if (pinned) this.container.scrollTop = this.container.scrollHeight;
23
+ return row;
23
24
  }
24
25
  finish({ role, transcript, turnId }) {
25
26
  if (!['user', 'assistant'].includes(role) || (turnId && this.completed.has(turnId))) return;
@@ -0,0 +1,241 @@
1
+ const MAX_PENDING = 64;
2
+ const MAX_REPORT_ATTEMPTS = 3;
3
+ const REPORT_RETRY_DELAYS_MS = [1_000, 2_000];
4
+ const CLIPPING_OVERFLOW = new Set(['auto', 'clip', 'hidden', 'scroll']);
5
+
6
+ function referenceKey(reference) {
7
+ if (reference?.kind === 'message' && typeof reference.messageId === 'string')
8
+ return `message:${reference.messageId}`;
9
+ if (reference?.kind === 'native-item' && typeof reference.nativeItemId === 'string')
10
+ return `native-item:${reference.nativeItemId}`;
11
+ return null;
12
+ }
13
+
14
+ function intersect(bounds, rect) {
15
+ return {
16
+ left: Math.max(bounds.left, rect.left),
17
+ top: Math.max(bounds.top, rect.top),
18
+ right: Math.min(bounds.right, rect.right),
19
+ bottom: Math.min(bounds.bottom, rect.bottom),
20
+ };
21
+ }
22
+
23
+ function nodeIsVisible(document, node) {
24
+ if (document.visibilityState !== 'visible' || node?.isConnected !== true) return false;
25
+ const view = document.defaultView;
26
+ if (!view) return false;
27
+ const rects = node.getClientRects?.();
28
+ if (!rects || rects.length === 0) return false;
29
+ const viewport = {
30
+ left: 0,
31
+ top: 0,
32
+ right: document.documentElement?.clientWidth ?? view.innerWidth,
33
+ bottom: document.documentElement?.clientHeight ?? view.innerHeight,
34
+ };
35
+ let visible = intersect(viewport, node.getBoundingClientRect());
36
+ for (let current = node; current; current = current.parentElement) {
37
+ if (current.hidden || current.getAttribute?.('aria-hidden') === 'true') return false;
38
+ const style = view.getComputedStyle?.(current);
39
+ if (style?.display === 'none' || style?.visibility === 'hidden' || style?.visibility === 'collapse'
40
+ || style?.opacity === '0') return false;
41
+ const clipsX = CLIPPING_OVERFLOW.has(style?.overflowX ?? style?.overflow);
42
+ const clipsY = CLIPPING_OVERFLOW.has(style?.overflowY ?? style?.overflow);
43
+ if ((clipsX || clipsY) && current !== node) {
44
+ const bounds = current.getBoundingClientRect?.();
45
+ if (bounds) visible = intersect(visible, {
46
+ left: clipsX ? bounds.left : visible.left,
47
+ right: clipsX ? bounds.right : visible.right,
48
+ top: clipsY ? bounds.top : visible.top,
49
+ bottom: clipsY ? bounds.bottom : visible.bottom,
50
+ });
51
+ }
52
+ }
53
+ return visible.right > visible.left && visible.bottom > visible.top;
54
+ }
55
+
56
+ function currentNodes(candidate) {
57
+ try {
58
+ const nodes = typeof candidate.nodes === 'function' ? candidate.nodes() : candidate.nodes;
59
+ return Array.isArray(nodes) ? nodes.filter(Boolean) : [];
60
+ } catch {
61
+ return [];
62
+ }
63
+ }
64
+
65
+ function errorCode(value) {
66
+ return typeof value?.code === 'string' ? value.code : undefined;
67
+ }
68
+
69
+ /**
70
+ * Reports the first assistant text that reaches a visible renderer surface.
71
+ * A post-mutation animation frame plus DOM/document/viewport checks is not a
72
+ * claim that the compositor painted, the window was unoccluded, or a person read it.
73
+ */
74
+ export class FirstVisibleTextReceipt {
75
+ constructor({ document, report, now = Date.now, frame } = {}) {
76
+ this.document = document;
77
+ this.report = report;
78
+ this.now = now;
79
+ this.frame = frame ?? document?.defaultView?.requestAnimationFrame?.bind(document.defaultView);
80
+ this.pending = new Map();
81
+ this.rejected = new Set();
82
+ this.onVisibilityChange = () => this.retry();
83
+ this.onGeometryChange = () => this.retry();
84
+ document?.addEventListener?.('visibilitychange', this.onVisibilityChange);
85
+ document?.addEventListener?.('scroll', this.onGeometryChange, true);
86
+ document?.defaultView?.addEventListener?.('resize', this.onGeometryChange);
87
+ }
88
+
89
+ setCall(callId) {
90
+ callId ??= null;
91
+ if (callId === this.callId) return;
92
+ this.callId = callId;
93
+ this.pending.clear();
94
+ this.rejected.clear();
95
+ this.reported = false;
96
+ this.exhausted = false;
97
+ }
98
+
99
+ observe({ callId, reference, nodes } = {}) {
100
+ const key = referenceKey(reference);
101
+ if (!key || !callId || callId !== this.callId || this.reported || this.exhausted
102
+ || this.rejected.has(key)
103
+ || (!Array.isArray(nodes) && typeof nodes !== 'function')) return false;
104
+ const existing = this.pending.get(key);
105
+ const candidate = existing ?? { callId, reference, nodes, attempts: 0, retryAt: 0 };
106
+ candidate.nodes = nodes;
107
+ if (!existing && currentNodes(candidate).length === 0) return false;
108
+ this.pending.set(key, candidate);
109
+ while (this.pending.size > MAX_PENDING) this.pending.delete(this.pending.keys().next().value);
110
+ this.retry();
111
+ return true;
112
+ }
113
+
114
+ retry() {
115
+ if (this.scheduled || this.inFlight || this.reported || this.exhausted || !this.callId || this.pending.size === 0
116
+ || typeof this.frame !== 'function') return;
117
+ this.scheduled = true;
118
+ this.frame(() => {
119
+ this.scheduled = false;
120
+ void this.flush();
121
+ });
122
+ }
123
+
124
+ async flush() {
125
+ if (this.inFlight || this.reported || this.exhausted || !this.callId) return;
126
+ const entry = [...this.pending].find(([, candidate]) => currentNodes(candidate).some(node =>
127
+ nodeIsVisible(this.document, node)));
128
+ if (!entry) return;
129
+ const [key, candidate] = entry;
130
+ if (candidate.retryAt > this.now()) return;
131
+ const callId = this.callId;
132
+ const attempt = { callId, key };
133
+ this.inFlight = attempt;
134
+ candidate.attempts += 1;
135
+ let continueWithNext = false;
136
+ try {
137
+ const reply = await this.report({
138
+ event: 'first_text_visible', observedAt: this.now(), callId, reference: candidate.reference,
139
+ });
140
+ if (reply?.kind === 'ok' && this.callId === callId && this.pending.get(key) === candidate) {
141
+ this.reported = true;
142
+ this.pending.clear();
143
+ } else if (reply?.kind !== 'ok') {
144
+ continueWithNext = this.rejectCandidate(callId, key, candidate, reply);
145
+ }
146
+ } catch (error) {
147
+ continueWithNext = this.rejectCandidate(callId, key, candidate, error);
148
+ } finally {
149
+ if (this.inFlight === attempt) {
150
+ this.inFlight = undefined;
151
+ if (this.callId !== callId || continueWithNext) this.retry();
152
+ }
153
+ }
154
+ }
155
+
156
+ rejectCandidate(callId, key, candidate, failure) {
157
+ if (this.callId !== callId || this.pending.get(key) !== candidate) return false;
158
+ if (errorCode(failure) === 'invalid_request') {
159
+ this.pending.delete(key);
160
+ if (this.rejected.size >= MAX_PENDING) {
161
+ this.exhausted = true;
162
+ this.pending.clear();
163
+ return false;
164
+ }
165
+ this.rejected.add(key);
166
+ return true;
167
+ }
168
+ if (candidate.attempts >= MAX_REPORT_ATTEMPTS) {
169
+ // The first visible candidate was plausible but the Host never confirmed it.
170
+ // Preserve that result as unknown instead of reporting later text as the first.
171
+ this.exhausted = true;
172
+ this.pending.clear();
173
+ return false;
174
+ }
175
+ candidate.retryAt = this.now() + REPORT_RETRY_DELAYS_MS[candidate.attempts - 1];
176
+ return false;
177
+ }
178
+
179
+ close() {
180
+ this.document?.removeEventListener?.('visibilitychange', this.onVisibilityChange);
181
+ this.document?.removeEventListener?.('scroll', this.onGeometryChange, true);
182
+ this.document?.defaultView?.removeEventListener?.('resize', this.onGeometryChange);
183
+ this.pending.clear();
184
+ this.rejected.clear();
185
+ }
186
+ }
187
+
188
+ /** Owns the official companion's four subtitle projections and their receipt nodes. */
189
+ export class VisibleTextPresentation {
190
+ constructor({ document, report, transcript, callTranscript, bubble, callBubble }) {
191
+ this.receipt = new FirstVisibleTextReceipt({ document, report });
192
+ Object.assign(this, { transcript, callTranscript, bubble, callBubble });
193
+ }
194
+
195
+ setCall(callId) {
196
+ this.callTranscript.setCall(callId);
197
+ this.receipt.setCall(callId);
198
+ }
199
+
200
+ appendLive(event, speaker) {
201
+ const reference = event.itemId
202
+ ? { kind: 'native-item', nativeItemId: event.itemId }
203
+ : undefined;
204
+ const stableNodes = [
205
+ this.transcript.append(event.role, event.text, true),
206
+ this.callTranscript.appendLive(event),
207
+ ];
208
+ this.bubble.append(event.role, event.text, speaker, reference);
209
+ this.callBubble.append(event.role, event.text, speaker, reference);
210
+ if (event.role === 'assistant' && reference && typeof event.text === 'string'
211
+ && event.text.trim().length > 0) this.receipt.observe({
212
+ callId: event.callId,
213
+ reference,
214
+ nodes: () => [
215
+ ...stableNodes,
216
+ ...this.bubble.rowsForReference(reference),
217
+ ...this.callBubble.rowsForReference(reference),
218
+ ],
219
+ });
220
+ }
221
+
222
+ loadTranscript(callId, reply, companionName) {
223
+ if (!this.callTranscript.load(reply)) return false;
224
+ this.callBubble.loadTranscript(reply.rows, companionName);
225
+ for (const row of reply.rows) {
226
+ if (row.role !== 'assistant' || row.text.trim().length === 0) continue;
227
+ const reference = { kind: 'message', messageId: row.messageId };
228
+ const transcriptRow = this.callTranscript.rowForMessage(row.messageId);
229
+ this.receipt.observe({
230
+ callId,
231
+ reference,
232
+ nodes: () => [transcriptRow, ...this.callBubble.rowsForReference(reference)],
233
+ });
234
+ }
235
+ this.receipt.retry();
236
+ return true;
237
+ }
238
+
239
+ retry() { this.receipt.retry(); }
240
+ close() { this.receipt.close(); }
241
+ }