@clowder-ai/companion 0.1.0-alpha.27 → 0.1.0-alpha.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pluginId": "official.companion",
3
- "version": "0.1.0-alpha.27",
3
+ "version": "0.1.0-alpha.29",
4
4
  "contractVersion": "0.1.0",
5
5
  "name": "猫猫球",
6
6
  "icon": {
@@ -40,7 +40,7 @@
40
40
  "bridgeVersion": "1.5.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-Q5N69iKFCZpX1mGm9KJWOvJaU3xd5mdGbojDBRZUTx4="
43
+ "integrity": "sha256-NnvxPxATjH/A07F1gPzojizh05+046z4melKWHh0K7c="
44
44
  },
45
45
  "presentation": {
46
46
  "width": 300,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clowder-ai/companion",
3
- "version": "0.1.0-alpha.27",
3
+ "version": "0.1.0-alpha.29",
4
4
  "description": "The desktop voice body for your Clowder companion",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/plugin.yaml CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pluginId": "official.companion",
3
- "version": "0.1.0-alpha.27",
3
+ "version": "0.1.0-alpha.29",
4
4
  "contractVersion": "0.1.0",
5
5
  "name": "猫猫球",
6
6
  "icon": {
@@ -40,7 +40,7 @@
40
40
  "bridgeVersion": "1.5.0",
41
41
  "surface": {
42
42
  "entrypoint": "renderer/index.html",
43
- "integrity": "sha256-Q5N69iKFCZpX1mGm9KJWOvJaU3xd5mdGbojDBRZUTx4="
43
+ "integrity": "sha256-NnvxPxATjH/A07F1gPzojizh05+046z4melKWHh0K7c="
44
44
  },
45
45
  "presentation": {
46
46
  "width": 300,
@@ -0,0 +1,179 @@
1
+ /** Presentation-only state for the active call surface. Media remains owned by explicit actions. */
2
+ export class CallControlsPresentation {
3
+ constructor({
4
+ document,
5
+ root,
6
+ pet,
7
+ input,
8
+ composers = [],
9
+ send = async () => false,
10
+ accepted = () => {},
11
+ changed = () => {},
12
+ collapseDelay = 1800,
13
+ schedule = (callback, delay) => setTimeout(callback, delay),
14
+ cancel = handle => clearTimeout(handle),
15
+ }) {
16
+ this.document = document;
17
+ this.root = root;
18
+ this.pet = pet;
19
+ this.input = input;
20
+ this.composers = composers;
21
+ this.send = send;
22
+ this.accepted = accepted;
23
+ this.changed = changed;
24
+ this.collapseDelay = collapseDelay;
25
+ this.schedule = schedule;
26
+ this.cancel = cancel;
27
+ this.presentation = 'lite';
28
+ this.panel = 'none';
29
+ this.active = false;
30
+ this.mediaLocked = false;
31
+ this.petHovered = false;
32
+ this.controlsHovered = false;
33
+ this.timer = undefined;
34
+
35
+ this.onPetEnter = () => {
36
+ this.petHovered = true;
37
+ if (this.active) this.reveal();
38
+ };
39
+ this.onPetLeave = () => {
40
+ this.petHovered = false;
41
+ this.armCollapse();
42
+ };
43
+ this.onControlsEnter = () => {
44
+ this.controlsHovered = true;
45
+ this.clearTimer();
46
+ };
47
+ this.onControlsLeave = () => {
48
+ this.controlsHovered = false;
49
+ this.armCollapse();
50
+ };
51
+ this.onFocusIn = () => this.clearTimer();
52
+ this.onFocusOut = () => queueMicrotask(() => this.armCollapse());
53
+ this.onInput = () => this.reevaluate();
54
+
55
+ root.dataset.presentation = this.presentation;
56
+ pet.addEventListener('pointerenter', this.onPetEnter);
57
+ pet.addEventListener('pointerleave', this.onPetLeave);
58
+ root.addEventListener('pointerenter', this.onControlsEnter);
59
+ root.addEventListener('pointerleave', this.onControlsLeave);
60
+ root.addEventListener('focusin', this.onFocusIn);
61
+ root.addEventListener('focusout', this.onFocusOut);
62
+ input.addEventListener('input', this.onInput);
63
+ for (const composer of composers) {
64
+ composer.submit = event => this.submit(event, composer.input);
65
+ composer.form.onsubmit = composer.submit;
66
+ }
67
+ }
68
+
69
+ setComposerState(identity, connecting) {
70
+ for (const { form, input } of this.composers) {
71
+ input.placeholder = identity ? `发消息给${identity.displayName}…` : '发消息给猫猫…';
72
+ form.querySelector('button').disabled = !identity || connecting;
73
+ }
74
+ }
75
+
76
+ async submit(event, input) {
77
+ event.preventDefault();
78
+ const text = input.value;
79
+ if (!await this.send(text)) return;
80
+ if (input.value === text) input.value = '';
81
+ this.reevaluate();
82
+ this.accepted();
83
+ }
84
+
85
+ setActive(active) {
86
+ if (this.active === active) return;
87
+ this.active = active;
88
+ this.clearTimer();
89
+ if (!active) {
90
+ this.mediaLocked = false;
91
+ this.setPresentation('lite');
92
+ return;
93
+ }
94
+ this.setPresentation(this.mediaLocked ? 'controls' : 'lite');
95
+ }
96
+
97
+ setPanel(panel) {
98
+ this.panel = panel;
99
+ if (panel === 'actions') this.armCollapse();
100
+ else this.clearTimer();
101
+ }
102
+
103
+ setMediaLocked(locked) {
104
+ this.mediaLocked = locked;
105
+ if (!this.active) return;
106
+ if (locked) this.reveal();
107
+ else this.armCollapse();
108
+ }
109
+
110
+ activate() {
111
+ if (!this.active) return false;
112
+ this.reveal();
113
+ return true;
114
+ }
115
+
116
+ escape(panel) {
117
+ if (!this.active || panel !== 'actions') return false;
118
+ this.clearTimer();
119
+ if (!this.mediaLocked) this.setPresentation('lite');
120
+ return true;
121
+ }
122
+
123
+ reveal() {
124
+ if (!this.active) return;
125
+ this.clearTimer();
126
+ this.setPresentation('controls');
127
+ }
128
+
129
+ reevaluate() {
130
+ if (this.canAutoCollapse()) this.armCollapse();
131
+ else this.clearTimer();
132
+ }
133
+
134
+ armCollapse() {
135
+ this.clearTimer();
136
+ if (!this.canAutoCollapse()) return;
137
+ this.timer = this.schedule(() => {
138
+ this.timer = undefined;
139
+ if (this.canAutoCollapse()) this.setPresentation('lite');
140
+ }, this.collapseDelay);
141
+ }
142
+
143
+ canAutoCollapse() {
144
+ return this.active
145
+ && this.presentation === 'controls'
146
+ && !this.mediaLocked
147
+ && !this.petHovered
148
+ && !this.controlsHovered
149
+ && !this.root.contains(this.document.activeElement)
150
+ && this.input.value.trim() === '';
151
+ }
152
+
153
+ setPresentation(next) {
154
+ if (this.presentation === next) return;
155
+ this.presentation = next;
156
+ this.root.dataset.presentation = next;
157
+ this.changed(next);
158
+ }
159
+
160
+ clearTimer() {
161
+ if (this.timer === undefined) return;
162
+ this.cancel(this.timer);
163
+ this.timer = undefined;
164
+ }
165
+
166
+ close() {
167
+ this.clearTimer();
168
+ this.pet.removeEventListener('pointerenter', this.onPetEnter);
169
+ this.pet.removeEventListener('pointerleave', this.onPetLeave);
170
+ this.root.removeEventListener('pointerenter', this.onControlsEnter);
171
+ this.root.removeEventListener('pointerleave', this.onControlsLeave);
172
+ this.root.removeEventListener('focusin', this.onFocusIn);
173
+ this.root.removeEventListener('focusout', this.onFocusOut);
174
+ this.input.removeEventListener('input', this.onInput);
175
+ for (const composer of this.composers) {
176
+ if (composer.form.onsubmit === composer.submit) composer.form.onsubmit = null;
177
+ }
178
+ }
179
+ }
@@ -63,6 +63,7 @@
63
63
  <button id="call-chat" data-action="write"><svg class="icon" aria-hidden="true"><use href="#i-keyboard"/></svg><span>聊天</span><span id="call-chat-unread" class="action-unread" hidden></span></button>
64
64
  <button id="end" class="destructive" data-action="stop"><span>挂断</span></button>
65
65
  </div>
66
+ <form id="lite-call-compose"><input id="lite-call-message" aria-label="通话中给猫猫发消息" placeholder="发消息给猫猫…" maxlength="8000"><button type="submit" class="primary">发送</button></form>
66
67
  <p id="status" role="status" hidden></p>
67
68
  </section>
68
69
  <section id="menu" class="floating name-card" aria-label="猫猫名片" hidden>
@@ -118,26 +119,35 @@
118
119
  <div class="call-context-actions call-context-inline-actions"><button id="chat-mic" data-action="mute">静音</button><button id="chat-share" data-action="share"><span class="label">共享画面</span></button><button data-action="stop" class="destructive compact">挂断</button></div>
119
120
  <div id="chat-share-context" class="call-share-line" hidden><svg class="icon" aria-hidden="true"><use href="#i-screen"/></svg><span><strong id="chat-share-target"></strong><small>仅共享此窗口</small></span></div>
120
121
  </div>
121
- <header id="chat-history-header"><strong id="chat-name">和猫猫写几句</strong><button data-action="dismiss" aria-label="收起文字">收起</button></header>
122
- <div id="chat-transcript" role="log" aria-live="polite"><p class="empty">想说什么,就写在这里。</p></div>
123
- <button id="reply-return-latest" class="reply-return-latest" type="button" hidden>回到最新 ↓</button>
122
+ <header id="chat-history-header"><strong id="chat-name">和猫猫写几句</strong>
123
+ <button id="chat-history-dismiss" data-action="dismiss" aria-label="收起文字">收起</button>
124
+ </header>
125
+ <div id="chat-transcript-shell" class="chat-transcript-shell" hidden>
126
+ <div id="chat-transcript" role="log" aria-live="polite"><p class="empty">想说什么,就写在这里。</p></div>
127
+ <button id="reply-history-close" class="reply-history-close" data-action="dismiss" aria-label="关闭历史" title="关闭历史" hidden>
128
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4 4 8 8m0-8-8 8"/></svg>
129
+ </button>
130
+ <button id="reply-return-latest" class="reply-return-latest" type="button" hidden>回到最新 ↓</button>
131
+ </div>
124
132
  <div id="reply-sent" class="reply-sent" tabindex="-1" hidden>
125
133
  <svg viewBox="0 0 36 27" aria-hidden="true"><rect x="1" y="1" width="34" height="25" rx="4"/><path d="M2.5 3.5 18 15 33.5 3.5"/></svg>
126
- <span><strong id="reply-sent-status">已发送</strong><small id="reply-letter-text"></small></span>
134
+ <span><strong id="reply-sent-status">已发送</strong><small id="reply-letter-text" hidden></small></span>
127
135
  </div>
128
136
  <article id="reply-card" class="reply-card" hidden tabindex="-1">
129
137
  <div class="reply-head"><span id="reply-input-text" class="reply-input-text"></span>
130
138
  <button id="reply-retain" class="reply-ring" type="button" aria-label="留住回复" title="留住回复">
131
139
  <svg viewBox="0 0 16 16" aria-hidden="true"><circle class="reply-ring-track" cx="8" cy="8" r="6.5"/><circle class="reply-ring-progress" cx="8" cy="8" r="6.5"/></svg>
132
140
  </button>
133
- <button id="reply-close" class="reply-close" type="button" aria-label="关闭回复" title="关闭回复" hidden>×</button>
141
+ <button id="reply-close" class="reply-close" type="button" aria-label="关闭回复" title="关闭回复" hidden>
142
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4 4 8 8m0-8-8 8"/></svg>
143
+ </button>
134
144
  </div>
135
145
  <div id="reply-body" class="reply-body"></div>
136
146
  <small id="reply-body-status" class="reply-body-status" role="status"></small>
137
147
  <button id="reply-history-open" class="reply-history-open" type="button">往上翻聊天记录</button>
138
148
  </article>
139
149
  <form id="compose"><textarea id="message" aria-label="给猫猫输入消息" placeholder="输入消息…" rows="1" maxlength="8000"></textarea><button type="submit" class="primary">发送</button></form>
140
- <div class="chat-feedback"><small id="chat-status" role="status"></small><button id="reply-retry" type="button" hidden>↻ 重试</button></div>
150
+ <div id="chat-feedback" class="chat-feedback" hidden><small id="chat-status" role="status"></small><button id="reply-retry" type="button" hidden>↻ 重试</button></div>
141
151
  <small id="chat-connection-status" role="status"></small>
142
152
  <small id="chat-scope"><span id="connection-scope">语音与文字共用同一段聊天</span> <button id="history">在 Cat Café 中查看完整聊天 ↗</button></small>
143
153
  </section>
@@ -1,5 +1,7 @@
1
1
  /** Transient controls. The Host owns native placement, movement and hiding. */
2
- export function bindPetControls(client, { action, error, changed, moved = () => {} }) {
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
7
  const panelIds = ['actions', 'menu', 'chat', 'bubble', 'decisions', 'transcript', 'settings'];
@@ -72,6 +74,7 @@ export function bindPetControls(client, { action, error, changed, moved = () =>
72
74
  body.onclick = event => {
73
75
  if (suppressClick) { suppressClick = false; return; }
74
76
  if (event.detail > 1) return;
77
+ if (activate('click')) return;
75
78
  void show(panel === 'menu' ? 'none' : 'menu');
76
79
  };
77
80
  body.oncontextmenu = event => { event.preventDefault(); void show('menu'); };
@@ -100,7 +103,11 @@ export function bindPetControls(client, { action, error, changed, moved = () =>
100
103
  if (!event.target.closest('#anchor, .floating')) void show('none');
101
104
  });
102
105
  document.addEventListener('keydown', event => {
103
- if (event.key === 'Escape' && panel !== 'none') { event.preventDefault(); void show('none'); body.focus(); }
106
+ if (event.key === 'Escape' && panel !== 'none') {
107
+ event.preventDefault();
108
+ if (!escape(panel)) void show('none');
109
+ body.focus();
110
+ }
104
111
  });
105
112
  document.querySelectorAll('[data-action]').forEach(button => button.onclick = () => {
106
113
  const kind = button.dataset.action;
@@ -25,6 +25,7 @@ export class ReplyLetterController {
25
25
  this.reply = null;
26
26
  this.retained = false;
27
27
  this.hasNewer = false;
28
+ this.historyPresentation = null;
28
29
  this.selectedMessageId = null;
29
30
  this.pauseReasons = new Set();
30
31
  this.requestedContinuations = new Set();
@@ -95,6 +96,7 @@ export class ReplyLetterController {
95
96
  this.mode = 'compose';
96
97
  this.retained = false;
97
98
  this.hasNewer = false;
99
+ this.historyPresentation = null;
98
100
  this.selectedMessageId = null;
99
101
  this.pauseReasons.clear();
100
102
  this.emit();
@@ -164,6 +166,7 @@ export class ReplyLetterController {
164
166
  this.reply = presentationPair.reply;
165
167
  this.selectedMessageId = presentationPair.replyId;
166
168
  this.mode = 'reply';
169
+ this.historyPresentation = null;
167
170
  if (changedReply) {
168
171
  this.retained = false;
169
172
  this.pauseReasons.clear();
@@ -234,6 +237,7 @@ export class ReplyLetterController {
234
237
  this.stopTimer();
235
238
  this.reply = null;
236
239
  this.retained = false;
240
+ this.historyPresentation = null;
237
241
  this.selectedMessageId = null;
238
242
  this.letter = [...this.pending.values()].at(-1) ?? null;
239
243
  this.mode = this.letter ? 'sent' : 'idle';
@@ -258,6 +262,7 @@ export class ReplyLetterController {
258
262
  this.stopTimer();
259
263
  this.reply = null;
260
264
  this.retained = false;
265
+ this.historyPresentation = null;
261
266
  this.selectedMessageId = null;
262
267
  this.letter = restored ?? [...this.pending.values()].at(-1) ?? null;
263
268
  this.mode = this.letter ? 'sent' : 'idle';
@@ -275,6 +280,7 @@ export class ReplyLetterController {
275
280
  this.reply = null;
276
281
  this.retained = false;
277
282
  this.hasNewer = false;
283
+ this.historyPresentation = null;
278
284
  this.selectedMessageId = null;
279
285
  this.requestedContinuations.clear();
280
286
  this.dismissedReplies.clear();
@@ -287,6 +293,17 @@ export class ReplyLetterController {
287
293
  this.stopTimer();
288
294
  this.mode = 'history';
289
295
  this.retained = true;
296
+ this.historyPresentation = 'conversation';
297
+ this.selectedMessageId = messageId;
298
+ this.hasNewer = false;
299
+ this.emit();
300
+ }
301
+
302
+ openReplyHistory(messageId = newestHistoryAnchor(this.selectedMessageId, this.settled, this.history)) {
303
+ this.stopTimer();
304
+ this.mode = 'history';
305
+ this.retained = true;
306
+ this.historyPresentation = 'reply';
290
307
  this.selectedMessageId = messageId;
291
308
  this.hasNewer = false;
292
309
  this.emit();
@@ -325,6 +342,7 @@ export class ReplyLetterController {
325
342
  letter: this.letter,
326
343
  reply: this.reply,
327
344
  retained: this.retained,
345
+ historyPresentation: this.mode === 'history' ? this.historyPresentation : null,
328
346
  selectedMessageId: this.selectedMessageId,
329
347
  hasNewer: this.hasNewer,
330
348
  blockedReason: this.blockedReason,
@@ -1,9 +1,9 @@
1
1
  const phaseLabel = phase => ({
2
2
  processing: '正在回复',
3
- failed: '回复失败 · 已发送的消息仍在聊天中',
4
- interrupted: '回复中断 · 已发送的消息仍在聊天中',
5
- canceled: '回复已取消 · 已发送的消息仍在聊天中',
6
- withdrawn: '回复已撤回 · 已发送的消息仍在聊天中',
3
+ failed: '回复失败',
4
+ interrupted: '回复中断',
5
+ canceled: '回复已取消',
6
+ withdrawn: '回复已撤回',
7
7
  }[phase] ?? '已发送');
8
8
 
9
9
  const draftStatus = settlement => ({
@@ -52,28 +52,38 @@ export class ReplyLetterView {
52
52
  card.addEventListener('pointercancel', () => this.controller.resume('pointer'));
53
53
  this.$('reply-retain').onclick = () => this.controller.pin();
54
54
  this.$('reply-close').onclick = () => this.controller.close();
55
- this.$('reply-history-open').onclick = () => this.controller.openHistory();
55
+ this.$('reply-history-open').onclick = () => this.controller.openReplyHistory();
56
56
  this.$('reply-return-latest').onclick = () => this.controller.returnLatest();
57
57
  this.$('reply-retry').onclick = () => { if (this.value?.draft) this.retry(this.value.draft); };
58
58
  }
59
59
 
60
60
  render(value) {
61
61
  this.value = value;
62
- const compose = value.mode === 'compose' || value.mode === 'history';
63
62
  const history = value.mode === 'history';
63
+ const replyHistory = history && value.historyPresentation === 'reply';
64
+ const conversationHistory = history && !replyHistory;
65
+ const compose = value.mode === 'compose' || conversationHistory;
64
66
  this.$('compose').hidden = !compose;
65
- this.$('chat-history-header').hidden = !history;
67
+ this.$('chat-history-header').hidden = !conversationHistory;
68
+ this.$('chat-name').hidden = replyHistory;
69
+ this.$('chat-history-dismiss').hidden = replyHistory;
70
+ this.$('reply-history-close').hidden = !replyHistory;
71
+ this.$('chat-transcript-shell').hidden = !history;
72
+ this.$('chat-transcript-shell').dataset.hasNewer = String(history && value.hasNewer);
66
73
  this.$('chat-transcript').hidden = !history;
67
- this.$('chat-scope').hidden = !history;
74
+ this.$('chat-scope').hidden = !conversationHistory;
75
+ this.$('chat-connection-status').hidden = replyHistory;
68
76
  this.$('reply-sent').hidden = value.mode !== 'sent';
69
77
  this.$('reply-card').hidden = value.mode !== 'reply';
70
78
  this.$('reply-return-latest').hidden = !history || !value.hasNewer;
71
79
 
72
80
  const retrying = value.mode === 'compose' && value.draft?.settlement === 'unconfirmed';
73
81
  this.$('reply-retry').hidden = !retrying;
74
- this.$('chat-status').textContent = value.draft ? draftStatus(value.draft.settlement)
82
+ const feedback = value.draft ? draftStatus(value.draft.settlement)
75
83
  : value.draftNotice ? draftStatus(value.draftNotice)
76
84
  : value.blockedReason === 'permission_required' ? '聊天权限已撤销' : '';
85
+ this.$('chat-status').textContent = feedback;
86
+ this.$('chat-feedback').hidden = !compose || (!feedback && !retrying);
77
87
  if (value.draft) {
78
88
  const input = this.$('message');
79
89
  if (!input.value || input.value === this.lastRenderedDraft) input.value = value.draft.text;
@@ -81,7 +91,8 @@ export class ReplyLetterView {
81
91
  }
82
92
 
83
93
  if (value.letter) {
84
- this.$('reply-letter-text').textContent = value.letter.text;
94
+ this.$('reply-letter-text').textContent = '';
95
+ this.$('reply-letter-text').hidden = true;
85
96
  this.$('reply-input-text').textContent = value.letter.text;
86
97
  this.$('reply-sent-status').textContent = phaseLabel(value.letter.responsePhase);
87
98
  }
@@ -99,5 +110,7 @@ export class ReplyLetterView {
99
110
  this.$('reply-retain').dataset.running = String(canRetain && value.timer.running);
100
111
  this.$('reply-retain').style.setProperty('--reply-visible-ms', `${value.timer.remainingMs}ms`);
101
112
  this.$('chat').dataset.replyMode = value.mode;
113
+ this.$('chat').dataset.historyPresentation = history
114
+ ? value.historyPresentation ?? 'conversation' : '';
102
115
  }
103
116
  }
@@ -86,10 +86,23 @@ button:disabled { opacity: .45; cursor: default; }
86
86
  .floating .destructive:hover:not(:disabled) { background: #942c24; }
87
87
  .floating .destructive.compact { min-width: 72px; padding: 6px 12px; }
88
88
  #actions { padding: 12px; width: 412px; overflow: hidden; }
89
+ #actions[data-presentation="lite"] { width: 320px; display: flex; flex-direction: column; align-items: flex-end; border: 0; background: transparent; box-shadow: none; padding: 0; overflow: visible; }
90
+ #actions[data-presentation="lite"] > :not(.call-status-line, .call-recent) { display: none !important; }
91
+ #actions[data-presentation="lite"] .call-status-line { order: 2; min-height: 29px; width: fit-content; border: 1px solid #fff3; border-radius: 8px; background: var(--desktop-ink); box-shadow: 0 3px 8px #1817152e; color: var(--desktop-paper); padding: 5px 10px; }
92
+ #actions[data-presentation="lite"] .call-status-line strong { color: inherit; font-size: 12px; }
93
+ #actions[data-presentation="lite"] .call-status-line span:not(.status-dot) { color: #d8d1c6; font-size: 11px; }
94
+ #actions[data-presentation="lite"] .call-recent { order: 1; align-self: stretch; display: none; margin: 0 0 8px; border: 1px solid var(--desktop-line-strong); border-radius: 12px; background: var(--desktop-paper); box-shadow: 0 8px 18px #1817151f; padding: 9px 11px; }
95
+ #actions[data-presentation="lite"] .call-recent:has(p:not([hidden])) { display: block; }
96
+ #actions[data-presentation="lite"] .call-recent-label { display: none; }
97
+ #actions[data-presentation="lite"] .call-recent p { margin-top: 0; }
89
98
  .action-row { display: flex; align-items: center; gap: 4px; }
90
99
  .action-row button { display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 35px; font-size: 12px; white-space: nowrap; }
91
100
  .action-row button .icon { width: 16px; height: 16px; }
92
101
  #active-actions { border-top: 1px solid var(--desktop-line); margin-top: 9px; padding-top: 9px; }
102
+ #lite-call-compose { display: none; align-items: center; gap: 8px; border-top: 1px solid var(--desktop-line); margin-top: 9px; padding-top: 9px; }
103
+ #actions[data-presentation="controls"] #lite-call-compose { display: flex; }
104
+ #lite-call-message { min-width: 0; height: 34px; flex: 1; border: 1px solid var(--desktop-line-strong); border-radius: 8px; background: var(--desktop-paper); color: var(--desktop-ink); padding: 6px 9px; }
105
+ #lite-call-compose button { height: 34px; padding: 6px 12px; }
93
106
  #status { font-size: 12px; line-height: 1.45; margin: 8px 0 0; color: var(--desktop-muted); }
94
107
  .call-status-line { min-height: 24px; display: flex; align-items: center; gap: 7px; white-space: nowrap; }
95
108
  .call-status-line strong { font-size: 13px; font-weight: 600; }
@@ -188,11 +201,16 @@ button:disabled { opacity: .45; cursor: default; }
188
201
  #work-notice { margin: 0; padding: 3px 11px 7px; color: var(--desktop-muted); font-size: 10px; }
189
202
  #chat { width: 400px; max-height: 500px; display: flex; flex-direction: column; overflow: hidden; }
190
203
  #chat[data-reply-mode="compose"] { width: 340px; }
191
- #chat[data-reply-mode="sent"] { width: 210px; background: transparent; box-shadow: none; }
204
+ #chat[data-reply-mode="sent"] { width: max-content; border: 0; background: transparent; box-shadow: none; overflow: visible; }
192
205
  #chat[data-reply-mode="reply"] { width: 370px; }
206
+ #chat[data-reply-mode="history"][data-history-presentation="reply"] { width: 370px; }
193
207
  #chat-history-header { padding: 7px 9px 7px 15px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--cafe-border-subtle); }
194
208
  #chat-history-header button { font-size: 12px; color: var(--cafe-text-secondary); }
195
- #chat-transcript { height: 230px; overflow: auto; padding: 14px; line-height: 1.7; font-size: 13px; }
209
+ .chat-transcript-shell { position: relative; min-height: 0; flex: 1 1 230px; }
210
+ #chat[data-history-presentation="reply"] .chat-transcript-shell { flex-basis: 480px; }
211
+ #chat-transcript { height: 100%; overflow: auto; padding: 14px; line-height: 1.7; font-size: 13px; }
212
+ .chat-transcript-shell[data-has-newer="true"] #chat-transcript { padding-bottom: 56px; }
213
+ #chat[data-history-presentation="reply"] #chat-transcript { padding-top: 44px; }
196
214
  #chat-transcript p { margin: 0 0 10px; overflow-wrap: anywhere; white-space: pre-wrap; }
197
215
  #chat-transcript p:not(.empty) { width: fit-content; max-width: 92%; padding: 7px 10px; border-radius: 9px; }
198
216
  #chat-transcript p.user { margin-left: auto; background: var(--desktop-rail); }
@@ -201,32 +219,35 @@ button:disabled { opacity: .45; cursor: default; }
201
219
  #compose { display: flex; align-items: center; padding: 10px; gap: 8px; border-top: 1px solid var(--cafe-border-subtle); }
202
220
  #chat[data-reply-mode="compose"] #compose { border: 0; }
203
221
  #message { resize: none; border: 1px solid var(--desktop-line-strong); border-radius: 8px; min-width: 0; height: 34px; flex: 1; background: var(--desktop-paper); padding: 6px 9px; color: inherit; line-height: 1.5; }
222
+ #message:focus-visible { border-color: transparent; outline: 2px solid var(--cafe-accent); outline-offset: -2px; }
204
223
  .chat-feedback { min-height: 0; display: flex; align-items: center; gap: 8px; padding: 0 12px 8px; color: #8f4730; }
205
224
  .chat-feedback small { flex: 1; font-size: 11px; }
206
225
  .chat-feedback button { color: #8f4730; font-size: 11px; }
207
226
  #chat-connection-status, #chat-scope { color: var(--cafe-text-muted); font-size: 10px; padding: 0 14px 10px; }
208
- .reply-sent { align-items: center; gap: 9px; border-radius: 10px; background: var(--desktop-paper); box-shadow: 0 8px 24px #18171524; padding: 9px 11px; }
209
- .reply-sent:not([hidden]) { display: flex; }
227
+ #chat-connection-status:empty { display: none; }
228
+ .reply-sent { align-items: center; gap: 5px; background: transparent; padding: 0; }
229
+ .reply-sent:not([hidden]) { display: flex; flex-direction: column; }
210
230
  .reply-sent > svg { width: 36px; height: 27px; flex: none; overflow: visible; }
211
231
  .reply-sent > svg rect { fill: var(--desktop-paper); stroke: var(--desktop-ink); stroke-width: 1.5; }
212
232
  .reply-sent > svg path { fill: none; stroke: var(--desktop-ink); stroke-width: 1.5; stroke-linejoin: round; }
213
- .reply-sent > span { min-width: 0; display: grid; gap: 2px; }
214
- .reply-sent strong { font-size: 12px; font-weight: 600; }
215
- .reply-sent small { overflow: hidden; color: var(--desktop-muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
233
+ .reply-sent > span { min-width: 0; }
234
+ .reply-sent strong { display: block; border-radius: 999px; background: var(--desktop-ink); color: var(--desktop-paper); padding: 3px 8px; font-size: 10px; font-weight: 500; white-space: nowrap; }
235
+ .reply-sent small { display: none; }
216
236
  .reply-card { position: relative; max-height: 500px; overflow-y: auto; padding: 12px 14px 10px; }
217
237
  .reply-head { display: flex; align-items: flex-start; gap: 8px; }
218
238
  .reply-input-text { min-width: 0; flex: 1; color: var(--desktop-muted); font-size: 12px; line-height: 1.45; overflow-wrap: anywhere; }
219
- .reply-ring, .reply-close { width: 24px; height: 24px; flex: none; display: grid; place-items: center; padding: 0; color: var(--desktop-muted); }
239
+ #chat .reply-ring, #chat .reply-close, #chat .reply-history-close { width: 24px; height: 24px; flex: none; display: grid; place-items: center; padding: 0; color: var(--desktop-muted); }
220
240
  .reply-ring svg { width: 18px; height: 18px; transform: rotate(-90deg); }
221
- .reply-ring circle { fill: none; stroke-width: 1.5; }
222
- .reply-ring-track { stroke: var(--desktop-line-strong); }
223
- .reply-ring-progress { stroke: var(--desktop-ink); stroke-linecap: round; stroke-dasharray: 40.84; stroke-dashoffset: 0; }
241
+ .reply-ring circle { fill: none; stroke-width: 1; }
242
+ .reply-ring-track { stroke: var(--desktop-line); }
243
+ .reply-ring-progress { stroke: var(--desktop-muted); stroke-linecap: round; stroke-dasharray: 40.84; stroke-dashoffset: 0; }
224
244
  .reply-ring[data-running="true"] .reply-ring-progress { animation: reply-countdown var(--reply-visible-ms) linear forwards; }
225
- .reply-close { font-size: 20px; line-height: 1; }
245
+ .reply-close svg, .reply-history-close svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; }
246
+ .reply-history-close { position: absolute; top: 10px; right: 10px; z-index: 1; background: var(--desktop-rail); }
226
247
  .reply-body { margin-top: 7px; color: var(--desktop-ink); font-size: 14px; line-height: 1.65; overflow-wrap: anywhere; white-space: pre-wrap; }
227
248
  .reply-body-status { display: block; margin-top: 6px; color: var(--desktop-muted); font-size: 11px; }
228
249
  .reply-history-open { margin-top: 8px; padding: 3px 0; color: var(--desktop-muted); font-size: 11px; }
229
- .reply-return-latest { position: absolute; right: 12px; bottom: 59px; z-index: 1; border: 1px solid var(--desktop-line) !important; background: var(--desktop-paper) !important; box-shadow: 0 2px 8px #1817151a; color: #8f4730; font-size: 11px; }
250
+ .reply-return-latest { position: absolute; left: 50%; bottom: 10px; z-index: 1; transform: translateX(-50%); border: 1px solid var(--desktop-line) !important; background: var(--desktop-ink) !important; box-shadow: 0 2px 8px #1817151a; color: var(--desktop-paper); font-size: 11px; white-space: nowrap; }
230
251
  @keyframes reply-countdown { to { stroke-dashoffset: 40.84; } }
231
252
  .call-transcript { width: 420px; height: 210px; display: flex; flex-direction: column; overflow: hidden; }
232
253
  .call-transcript[data-expanded="true"] { height: 500px; }
@@ -21,6 +21,7 @@ import { WorkOverviewView } from './work-overview.mjs';
21
21
  import { MeetingStatus, renderMeetingStatus } from './meeting-state.mjs';
22
22
  import { VisibleTextPresentation } from './visible-text-receipt.mjs';
23
23
  import { ConversationSurface } from './conversation-surface.mjs';
24
+ import { CallControlsPresentation } from './call-controls-presentation.mjs';
24
25
  const $ = id => document.getElementById(id);
25
26
  const label = (id, text) => { $(id).querySelector('.label').textContent = text; };
26
27
  const status = text => {
@@ -50,7 +51,7 @@ const nativeTravel = new NativeWindowTravel({
50
51
  changed: value => motion.syncTravel(value),
51
52
  });
52
53
  let sharing = false, pendingScreen = false, transcriptLoading = false, previousPhase = 'idle';
53
- let didMove = false, settingsView, settingsValues, decisionPanel, workView;
54
+ let didMove = false, settingsView, settingsValues, decisionPanel, workView, callControlsPresentation;
54
55
  let previousControlPanel = 'none';
55
56
  // F229 stays quiet: a history ID change has no provenance for proactive text.
56
57
  const ambientPanel = active => active ? 'actions' : 'none';
@@ -70,6 +71,8 @@ if (!window.clowderCompanion) {
70
71
  transcript, callTranscript, bubble, callBubble,
71
72
  });
72
73
  const controls = bindPetControls(client, {
74
+ activate: () => { const handled = callControlsPresentation?.activate() ?? false; if (handled) void controls.show('actions'); return handled; },
75
+ escape: panel => callControlsPresentation?.escape(panel) ?? false,
73
76
  error: (error, context) => status(context?.panel === 'settings' && error?.code === 'invalid_request' ? '这个 Host 版本还不支持猫旁设置。原来的设置入口会继续保留。' : explainError(error)),
74
77
  moved: (dx, dy) => {
75
78
  if (dx !== 'stop') { didMove = true; nativeTravel.setManual(true); motion.move(dx, dy); return; }
@@ -81,6 +84,7 @@ if (!window.clowderCompanion) {
81
84
  setTimeout(() => motion.setDockedEdge(detectDockedEdge(window, $('pet').getBoundingClientRect())), 100);
82
85
  },
83
86
  changed: panel => {
87
+ callControlsPresentation?.setPanel(panel);
84
88
  visibleText.retry();
85
89
  if (previousControlPanel === 'chat' && panel !== 'chat') conversationSurface.closeHistory();
86
90
  if (panel === 'chat' && conversationSurface.controller.mode === 'idle') conversationSurface.openHistory();
@@ -123,15 +127,23 @@ if (!window.clowderCompanion) {
123
127
  }
124
128
  },
125
129
  });
130
+ callControlsPresentation = new CallControlsPresentation({
131
+ document, root: $('actions'), pet: $('pet'), input: $('lite-call-message'),
132
+ composers: [{ form: $('call-compose'), input: $('call-message') },
133
+ { form: $('lite-call-compose'), input: $('lite-call-message') }],
134
+ send: text => conversation.send(text),
135
+ accepted: () => { motion.signal('received'); void conversationSurface.readHistory(); },
136
+ changed: () => controls.remeasure(),
137
+ });
126
138
  const screen = new ScreenShare({
127
- screenRequest: () => client.screenPick(),
128
- screenStart: (selectionId, name) => client.screenOpen(selectionId, name),
139
+ screenRequest: () => client.screenPick(), screenStart: (selectionId, name) => client.screenOpen(selectionId, name),
129
140
  screenFrame: (selectionId, frame) => client.screenFrame(selectionId, {
130
141
  ...frame, frameId: crypto.randomUUID(), sourceLabel: '所选画面', observedAt: Date.now(),
131
142
  }), screenStop: () => client.screenClose(),
132
143
  }, value => {
133
144
  const wasActive = sharing || pendingScreen;
134
145
  sharing = value.sharing; pendingScreen = value.pending === true;
146
+ callControlsPresentation.setMediaLocked(sharing || pendingScreen);
135
147
  for (const id of ['share', 'menu-share', 'menu-call-share', 'chat-share', 'settings-share']) label(id, sharing ? '停止共享' : pendingScreen ? '取消选屏' : '共享画面');
136
148
  for (const prefix of ['call', 'menu', 'chat', 'transcript', 'settings']) {
137
149
  $(`${prefix}-share-context`).hidden = !sharing;
@@ -181,6 +193,7 @@ if (!window.clowderCompanion) {
181
193
  const canReceiveOnly = value.identity?.audio?.supportedModes?.includes('receive_only') === true;
182
194
  const retryReceiveOnly = value.failed && value.retryMode === 'receive_only' && canReceiveOnly;
183
195
  visibleText.setCall(value.callId);
196
+ callControlsPresentation.setActive(active);
184
197
  void controls.setAmbient(ambientPanel(active));
185
198
  $('begin').hidden = active; $('begin').disabled = !value.identity;
186
199
  $('listen').hidden = active || !canReceiveOnly; $('listen').disabled = !value.identity;
@@ -197,6 +210,7 @@ if (!window.clowderCompanion) {
197
210
  $('share').disabled = value.phase !== 'talking';
198
211
  $('menu-call-share').disabled = value.phase !== 'talking';
199
212
  $('compose').querySelector('button').disabled = !value.identity || value.phase === 'connecting';
213
+ callControlsPresentation.setComposerState(value.identity, value.phase === 'connecting');
200
214
  status(value.message ?? '');
201
215
  $('status').hidden = !value.message || /^(点击|正在听|只听模式|只听已结束|语音通话已结束|已发送)/.test(value.message);
202
216
  $('menu-status').hidden = !value.message;
@@ -232,8 +246,7 @@ if (!window.clowderCompanion) {
232
246
  const companionIdentity = currentCompanionIdentity(identity);
233
247
  renderMeetingStatus(document, meetingStatus.update(active ? identity?.meeting : null));
234
248
  const nativeSnapshot = normalizeNativeWork(identity?.nativeWork);
235
- const workLabel = nativeWorkLabel(nativeSnapshot, identity?.nativeActivity);
236
- const workState = workLabel ? `${identity.displayName}${workLabel}` : '';
249
+ const workLabel = nativeWorkLabel(nativeSnapshot, identity?.nativeActivity), workState = workLabel ? `${identity.displayName}${workLabel}` : '';
237
250
  setTexts(['call-work', 'chat-call-work', 'transcript-call-work', 'menu-call-work', 'settings-call-work'], workState);
238
251
  setHidden(['call-work', 'chat-call-work', 'transcript-call-work', 'menu-call-work', 'settings-call-work'], !workState);
239
252
  motion.setContext({ skin: identity?.skin, phase: value.phase,
@@ -248,8 +261,8 @@ if (!window.clowderCompanion) {
248
261
  ? `${companionIdentity.title} · ${companionIdentity.partnerLabel}` : identity.displayName;
249
262
  $('chat-name').textContent = conversationSurface?.threadTitle ?? companionLabel; $('menu-name').textContent = companionLabel;
250
263
  $('message').placeholder = `发消息给${identity.displayName}…`;
251
- $('call-message').placeholder = `发消息给${identity.displayName}…`;
252
- $('pet').setAttribute('aria-label', `${companionLabel}:点击或右键打开名片,拖动移动`);
264
+ $('pet').setAttribute('aria-label', active ? `${companionLabel}:指向或点击显示通话控制,右键打开名片,拖动移动`
265
+ : `${companionLabel}:点击或右键打开名片,拖动移动`);
253
266
  $('documents').querySelector('.menu-status').textContent = identity.documentsAllowed ? '开启' : '暂停';
254
267
  $('documents').setAttribute('aria-pressed', String(identity.documentsAllowed));
255
268
  if (companionIdentity) {
@@ -322,28 +335,16 @@ if (!window.clowderCompanion) {
322
335
  } finally { transcriptLoading = false; }
323
336
  }
324
337
  $('share-badge').onclick = toggleScreen;
325
- async function submitCallMessage(event) {
326
- event.preventDefault(); const input = $('call-message'); const text = input.value;
327
- if (await conversation.send(text)) {
328
- motion.signal('received');
329
- if (input.value === text) input.value = '';
330
- void conversationSurface.readHistory();
331
- }
332
- }
333
- $('call-compose').onsubmit = submitCallMessage;
334
338
  $('call-unread').onclick = () => callTranscript.scrollToBottom();
335
339
  const unsubscribe = client.subscribe(event => {
336
340
  if (event.kind === 'media-stopped') void conversation.hostStopped(event.reason);
337
341
  if (event.kind === 'view-dismiss') controls.dismiss();
338
342
  });
339
- const statusMonitor = setInterval(() => void conversation.refresh(), 1000);
340
- const transcriptMonitor = setInterval(() => void readCallTranscript(), 1000);
341
- const decisionMonitor = setInterval(() => { if (controls.panel !== 'decisions') void decisionPanel.read(); }, 15000);
342
- const workMonitor = setInterval(() => { if (controls.panel === 'menu') void workView.load(); }, 15000);
343
+ const statusMonitor = setInterval(() => void conversation.refresh(), 1000), transcriptMonitor = setInterval(() => void readCallTranscript(), 1000);
344
+ const decisionMonitor = setInterval(() => { if (controls.panel !== 'decisions') void decisionPanel.read(); }, 15000), workMonitor = setInterval(() => { if (controls.panel === 'menu') void workView.load(); }, 15000);
343
345
  void conversation.refresh(); conversationSurface.start(); void decisionPanel.read(); void settingsView.controller.load(); void controls.show('none');
344
346
  window.addEventListener('beforeunload', () => {
345
- clearInterval(statusMonitor); clearInterval(transcriptMonitor); clearInterval(decisionMonitor); clearInterval(workMonitor);
346
- conversationSurface.close();
347
+ clearInterval(statusMonitor); clearInterval(transcriptMonitor); clearInterval(decisionMonitor); clearInterval(workMonitor); conversationSurface.close(); callControlsPresentation.close();
347
348
  unsubscribe(); visibleText.close(); nativeTravel.close(); motion.close(); void conversation.end();
348
349
  });
349
350
  }