@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.
@@ -0,0 +1,343 @@
1
+ import { newestHistoryAnchor, projectReplyBody, selectReplyPresentation } from './reply-letter-state.mjs';
2
+
3
+ const replyVisibleDuration = text => Math.min(30_000, 6_000 + Math.ceil([...text].length / 8) * 1_000);
4
+
5
+ /**
6
+ * Transient presentation state for a Host-owned conversation.
7
+ *
8
+ * Inputs are normalized bridge rows. In particular, inputMessageIds must be
9
+ * supplied by the trusted Host projection; this controller never guesses a
10
+ * relationship from timestamps, call ids, adjacency or equal text.
11
+ */
12
+ export class ReplyLetterController {
13
+ constructor({ render, requestContinuation = () => {}, now = Date.now,
14
+ schedule = setTimeout, cancel = clearTimeout }) {
15
+ Object.assign(this, { render, requestContinuation, now });
16
+ this.schedule = (...args) => Reflect.apply(schedule, globalThis, args);
17
+ this.cancel = (...args) => Reflect.apply(cancel, globalThis, args);
18
+ this.mode = 'idle';
19
+ this.pending = new Map();
20
+ this.settled = [];
21
+ this.pairs = new Map();
22
+ this.history = [];
23
+ this.draft = null;
24
+ this.letter = null;
25
+ this.reply = null;
26
+ this.retained = false;
27
+ this.hasNewer = false;
28
+ this.selectedMessageId = null;
29
+ this.pauseReasons = new Set();
30
+ this.requestedContinuations = new Set();
31
+ this.dismissedReplies = new Set();
32
+ this.timerHandle = null;
33
+ this.timerStartedAt = null;
34
+ this.timerRemainingMs = 0;
35
+ this.blockedReason = null;
36
+ this.draftNotice = null;
37
+ }
38
+
39
+ recordDelivery(receipt, text, context = {}) {
40
+ this.blockedReason = null;
41
+ if (receipt.delivery !== 'accepted') {
42
+ this.draft = {
43
+ text,
44
+ clientMessageId: receipt.clientMessageId,
45
+ ...(context.submissionRevision === undefined ? {} : { submissionRevision: context.submissionRevision }),
46
+ ...(context.scopeRef === undefined ? {} : { scopeRef: context.scopeRef }),
47
+ settlement: receipt.delivery === 'rejected' ? receipt.reason : 'unconfirmed',
48
+ };
49
+ if (this.mode !== 'history') this.mode = 'compose';
50
+ this.emit();
51
+ return;
52
+ }
53
+ const letter = { text, clientMessageId: receipt.clientMessageId, inputMessageId: receipt.messageId };
54
+ const known = this.pending.get(receipt.messageId) ?? this.pairs.get(receipt.messageId)?.letter;
55
+ if (known?.clientMessageId === receipt.clientMessageId) {
56
+ if (this.draft?.clientMessageId === receipt.clientMessageId) this.draft = null;
57
+ this.emit();
58
+ return;
59
+ }
60
+ this.pending.set(receipt.messageId, letter);
61
+ if (context.restored === false || this.draft?.clientMessageId === receipt.clientMessageId) this.draft = null;
62
+ if (this.mode !== 'history') {
63
+ this.stopTimer();
64
+ this.reply = null;
65
+ this.letter = letter;
66
+ this.retained = false;
67
+ this.mode = 'sent';
68
+ }
69
+ this.emit();
70
+ }
71
+
72
+ invalidateDraft(value) {
73
+ this.draft = {
74
+ text: value.text,
75
+ clientMessageId: value.clientMessageId,
76
+ submissionRevision: value.submissionRevision,
77
+ scopeRef: value.scopeRef,
78
+ settlement: value.reason ?? 'scope_changed',
79
+ };
80
+ if (this.mode !== 'history') this.mode = 'compose';
81
+ this.emit();
82
+ }
83
+
84
+ setDraftNotice(value) {
85
+ if (this.draftNotice === value) return;
86
+ this.draftNotice = value;
87
+ this.emit();
88
+ }
89
+
90
+ openCompose() {
91
+ if (this.reply) this.dismissedReplies.add(this.reply.id);
92
+ this.stopTimer();
93
+ this.reply = null;
94
+ this.letter = null;
95
+ this.mode = 'compose';
96
+ this.retained = false;
97
+ this.hasNewer = false;
98
+ this.selectedMessageId = null;
99
+ this.pauseReasons.clear();
100
+ this.emit();
101
+ }
102
+
103
+ loadHistory(rows) {
104
+ this.history = rows.map(row => ({
105
+ id: row.id,
106
+ role: row.role,
107
+ text: row.text,
108
+ body: projectReplyBody(row),
109
+ inputMessageIds: [...(row.inputMessageIds ?? [])],
110
+ responsePhase: row.responsePhase ?? 'unknown',
111
+ deliveryReceipt: row.deliveryReceipt ? { ...row.deliveryReceipt } : undefined,
112
+ }));
113
+ let restoredLetter = null;
114
+ for (const row of this.history) {
115
+ const receipt = row.deliveryReceipt;
116
+ if (row.role !== 'user' || receipt?.delivery !== 'accepted' || receipt.messageId !== row.id) continue;
117
+ if (this.pending.has(row.id) || this.pairs.has(row.id)) continue;
118
+ restoredLetter = {
119
+ text: row.text,
120
+ clientMessageId: receipt.clientMessageId,
121
+ inputMessageId: row.id,
122
+ };
123
+ this.pending.set(row.id, restoredLetter);
124
+ }
125
+ let newestPair = null;
126
+ const settledNow = [];
127
+ for (const row of this.history) {
128
+ if (row.role !== 'assistant') continue;
129
+ if (row.responsePhase === 'completed') this.requestMissingBody(row);
130
+ for (const inputMessageId of row.inputMessageIds) {
131
+ const pending = this.pending.get(inputMessageId);
132
+ const existing = this.pairs.get(inputMessageId);
133
+ if (!pending && !existing) continue;
134
+ if (row.responsePhase !== 'completed') {
135
+ if (pending) {
136
+ const updated = { ...pending, responsePhase: row.responsePhase };
137
+ this.pending.set(inputMessageId, updated);
138
+ if (this.letter?.inputMessageId === inputMessageId) this.letter = updated;
139
+ }
140
+ continue;
141
+ }
142
+ const letter = pending ?? existing.letter;
143
+ const pair = { inputMessageId, replyId: row.id, letter, reply: this.replyValue(row) };
144
+ if (!existing) {
145
+ this.pending.delete(inputMessageId);
146
+ this.settled.push({ inputMessageId, replyId: row.id });
147
+ settledNow.push(pair);
148
+ }
149
+ this.pairs.set(inputMessageId, pair);
150
+ newestPair = pair;
151
+ }
152
+ }
153
+
154
+ const presentationPair = selectReplyPresentation({ mode: this.mode, letter: this.letter,
155
+ reply: this.reply, settledPairs: settledNow, pairs: this.pairs,
156
+ dismissedReplies: this.dismissedReplies });
157
+ if (newestPair && this.mode === 'history') {
158
+ if (newestPair.replyId !== this.selectedMessageId) this.hasNewer = true;
159
+ } else if (presentationPair) {
160
+ const changedReply = this.reply?.id !== presentationPair.replyId;
161
+ const becameComplete = this.reply?.id === presentationPair.replyId
162
+ && this.reply.complete === false && presentationPair.reply.complete === true;
163
+ this.letter = presentationPair.letter;
164
+ this.reply = presentationPair.reply;
165
+ this.selectedMessageId = presentationPair.replyId;
166
+ this.mode = 'reply';
167
+ if (changedReply) {
168
+ this.retained = false;
169
+ this.pauseReasons.clear();
170
+ this.stopTimer();
171
+ }
172
+ if ((changedReply || becameComplete) && presentationPair.reply.complete) {
173
+ this.timerRemainingMs = replyVisibleDuration(presentationPair.reply.text);
174
+ this.startTimer();
175
+ }
176
+ } else if (this.mode === 'sent') {
177
+ this.letter = [...this.pending.values()].at(-1) ?? null;
178
+ if (!this.letter) this.mode = 'idle';
179
+ } else if (restoredLetter && this.mode === 'idle') {
180
+ this.letter = restoredLetter;
181
+ this.mode = 'sent';
182
+ }
183
+ this.emit();
184
+ }
185
+
186
+ replyValue(row) {
187
+ return { id: row.id, text: row.text, complete: row.body.complete,
188
+ responsePhase: row.responsePhase };
189
+ }
190
+
191
+ requestMissingBody(row) {
192
+ const reference = row.body.continuation;
193
+ if (row.body.complete || !reference) {
194
+ for (const key of this.requestedContinuations) {
195
+ if (key.startsWith(`${row.id}\u0000`)) this.requestedContinuations.delete(key);
196
+ }
197
+ return;
198
+ }
199
+ const key = `${row.id}\u0000${reference}`;
200
+ if (this.requestedContinuations.has(key)) return;
201
+ this.requestedContinuations.add(key);
202
+ this.requestContinuation({ messageId: row.id, continuation: reference });
203
+ }
204
+
205
+ releaseContinuation(messageId, continuation) {
206
+ this.requestedContinuations.delete(`${messageId}\u0000${continuation}`);
207
+ }
208
+
209
+ pause(reason) {
210
+ if (this.pauseReasons.has(reason)) return;
211
+ this.pauseReasons.add(reason);
212
+ if (this.timerHandle !== null) {
213
+ this.timerRemainingMs = Math.max(0, this.timerRemainingMs - (this.now() - this.timerStartedAt));
214
+ this.stopTimer(false);
215
+ }
216
+ this.emit();
217
+ }
218
+
219
+ resume(reason) {
220
+ if (!this.pauseReasons.delete(reason)) return;
221
+ if (this.pauseReasons.size === 0) this.startTimer();
222
+ this.emit();
223
+ }
224
+
225
+ pin() {
226
+ if (this.mode !== 'reply') return;
227
+ this.retained = true;
228
+ this.stopTimer();
229
+ this.emit();
230
+ }
231
+
232
+ close() {
233
+ if (this.reply) this.dismissedReplies.add(this.reply.id);
234
+ this.stopTimer();
235
+ this.reply = null;
236
+ this.retained = false;
237
+ this.selectedMessageId = null;
238
+ this.letter = [...this.pending.values()].at(-1) ?? null;
239
+ this.mode = this.letter ? 'sent' : 'idle';
240
+ this.emit();
241
+ }
242
+
243
+ withdrawReply(replyId) {
244
+ let restored = null;
245
+ for (const [inputMessageId, pair] of this.pairs) {
246
+ if (pair.replyId !== replyId) continue;
247
+ restored = { ...pair.letter, responsePhase: 'withdrawn' };
248
+ this.pairs.delete(inputMessageId);
249
+ this.pending.set(inputMessageId, restored);
250
+ }
251
+ this.settled = this.settled.filter(value => value.replyId !== replyId);
252
+ this.history = this.history.filter(value => value.id !== replyId);
253
+ this.dismissedReplies.delete(replyId);
254
+ for (const key of this.requestedContinuations) {
255
+ if (key.startsWith(`${replyId}\u0000`)) this.requestedContinuations.delete(key);
256
+ }
257
+ if (this.reply?.id === replyId) {
258
+ this.stopTimer();
259
+ this.reply = null;
260
+ this.retained = false;
261
+ this.selectedMessageId = null;
262
+ this.letter = restored ?? [...this.pending.values()].at(-1) ?? null;
263
+ this.mode = this.letter ? 'sent' : 'idle';
264
+ }
265
+ this.emit();
266
+ }
267
+
268
+ clearProtected(reason) {
269
+ this.stopTimer();
270
+ this.pending.clear();
271
+ this.settled = [];
272
+ this.pairs.clear();
273
+ this.history = [];
274
+ this.letter = null;
275
+ this.reply = null;
276
+ this.retained = false;
277
+ this.hasNewer = false;
278
+ this.selectedMessageId = null;
279
+ this.requestedContinuations.clear();
280
+ this.dismissedReplies.clear();
281
+ this.blockedReason = reason;
282
+ this.mode = this.draft ? 'compose' : 'idle';
283
+ this.emit();
284
+ }
285
+
286
+ openHistory(messageId = newestHistoryAnchor(this.selectedMessageId, this.settled, this.history)) {
287
+ this.stopTimer();
288
+ this.mode = 'history';
289
+ this.retained = true;
290
+ this.selectedMessageId = messageId;
291
+ this.hasNewer = false;
292
+ this.emit();
293
+ }
294
+
295
+ returnLatest() {
296
+ this.selectedMessageId = this.settled.at(-1)?.replyId ?? this.history.at(-1)?.id ?? null;
297
+ this.hasNewer = false;
298
+ this.emit();
299
+ }
300
+
301
+ startTimer() {
302
+ if (this.timerHandle !== null || this.timerRemainingMs <= 0 || this.retained
303
+ || this.mode !== 'reply' || !this.reply?.complete || this.pauseReasons.size > 0) return;
304
+ this.timerStartedAt = this.now();
305
+ this.timerHandle = this.schedule(() => {
306
+ this.timerHandle = null;
307
+ this.timerStartedAt = null;
308
+ this.timerRemainingMs = 0;
309
+ this.close();
310
+ }, this.timerRemainingMs);
311
+ }
312
+
313
+ stopTimer(reset = true) {
314
+ if (this.timerHandle !== null) this.cancel(this.timerHandle);
315
+ this.timerHandle = null;
316
+ this.timerStartedAt = null;
317
+ if (reset) this.timerRemainingMs = 0;
318
+ }
319
+
320
+ snapshot() {
321
+ const elapsed = this.timerHandle === null ? 0 : this.now() - this.timerStartedAt;
322
+ return {
323
+ mode: this.mode,
324
+ draft: this.draft,
325
+ letter: this.letter,
326
+ reply: this.reply,
327
+ retained: this.retained,
328
+ selectedMessageId: this.selectedMessageId,
329
+ hasNewer: this.hasNewer,
330
+ blockedReason: this.blockedReason,
331
+ draftNotice: this.draftNotice,
332
+ pendingInputMessageIds: [...this.pending.keys()],
333
+ settled: this.settled.map(value => ({ ...value })),
334
+ history: this.history.map(row => ({ ...row, body: { ...row.body }, inputMessageIds: [...row.inputMessageIds] })),
335
+ timer: {
336
+ running: this.timerHandle !== null,
337
+ remainingMs: Math.max(0, this.timerRemainingMs - elapsed),
338
+ },
339
+ };
340
+ }
341
+
342
+ emit() { this.render(this.snapshot()); }
343
+ }
@@ -0,0 +1,38 @@
1
+ export const projectReplyBody = row => ({
2
+ revision: row.body?.revision ?? '',
3
+ complete: row.body?.complete === true,
4
+ continuation: row.body?.continuation ?? null,
5
+ });
6
+
7
+ function newestSettledPair(settledPairs, dismissedReplies, inputMessageId) {
8
+ for (let index = settledPairs.length - 1; index >= 0; index -= 1) {
9
+ const pair = settledPairs[index];
10
+ if (dismissedReplies.has(pair.replyId)) continue;
11
+ if (inputMessageId !== undefined && pair.inputMessageId !== inputMessageId) continue;
12
+ return pair;
13
+ }
14
+ return undefined;
15
+ }
16
+
17
+ /** Only a newly settled relevant reply, or the reply already on screen, may take presentation focus. */
18
+ export function selectReplyPresentation({ mode, letter, reply, settledPairs, pairs, dismissedReplies }) {
19
+ if (mode === 'compose' || mode === 'history') return undefined;
20
+ if (mode === 'sent') {
21
+ if (!letter) return undefined;
22
+ return newestSettledPair(settledPairs, dismissedReplies, letter.inputMessageId);
23
+ }
24
+ const freshPair = newestSettledPair(settledPairs, dismissedReplies);
25
+ if (mode === 'idle' || (mode === 'reply' && freshPair)) return freshPair;
26
+ if (mode !== 'reply' || !reply) return undefined;
27
+ for (const pair of pairs.values()) {
28
+ if (pair.replyId === reply.id) return pair;
29
+ }
30
+ return undefined;
31
+ }
32
+
33
+ export function newestHistoryAnchor(selectedMessageId, settled, history) {
34
+ if (selectedMessageId !== null) return selectedMessageId;
35
+ const settledReplyId = settled.at(-1)?.replyId;
36
+ if (settledReplyId !== undefined) return settledReplyId;
37
+ return history.at(-1)?.id ?? null;
38
+ }
@@ -0,0 +1,103 @@
1
+ const phaseLabel = phase => ({
2
+ processing: '正在回复',
3
+ failed: '回复失败 · 已发送的消息仍在聊天中',
4
+ interrupted: '回复中断 · 已发送的消息仍在聊天中',
5
+ canceled: '回复已取消 · 已发送的消息仍在聊天中',
6
+ withdrawn: '回复已撤回 · 已发送的消息仍在聊天中',
7
+ }[phase] ?? '已发送');
8
+
9
+ const draftStatus = settlement => ({
10
+ unconfirmed: '发送结果未确认',
11
+ scope_changed: '原聊天已变化 · 这条消息没有重发',
12
+ pending_limit: '待确认消息已达 32 条 · 这条没有发送',
13
+ draft_changed: '草稿已在别处变化 · 请核对后再发送',
14
+ unsupported_draft: '当前草稿含附件或过长 · 请回 Cat Café 继续编辑',
15
+ conflict: '草稿已在别处变化 · 本地输入仍保留',
16
+ readonly: '当前草稿含附件或过长 · 请回 Cat Café 继续编辑',
17
+ permission_required: '聊天权限已撤销 · 本地输入仍保留',
18
+ unavailable: '草稿暂未同步 · 本地输入仍保留',
19
+ }[settlement] ?? '');
20
+
21
+ /** DOM projection for compose → sent letter → complete reply → retained history. */
22
+ export class ReplyLetterView {
23
+ constructor({ document, controller, retry, schedule = setTimeout, cancel = clearTimeout }) {
24
+ this.$ = id => document.getElementById(id);
25
+ Object.assign(this, { controller, retry });
26
+ this.schedule = (...args) => Reflect.apply(schedule, globalThis, args);
27
+ this.cancel = (...args) => Reflect.apply(cancel, globalThis, args);
28
+ this.bind();
29
+ }
30
+
31
+ bind() {
32
+ const card = this.$('reply-card');
33
+ card.addEventListener('pointerenter', () => this.controller.pause('hover'));
34
+ card.addEventListener('pointerleave', () => this.controller.resume('hover'));
35
+ card.addEventListener('focusin', () => this.controller.pause('focus'));
36
+ card.addEventListener('focusout', event => {
37
+ if (!event.relatedTarget || !card.contains?.(event.relatedTarget)) this.controller.resume('focus');
38
+ });
39
+ const pauseScroll = () => {
40
+ this.controller.pause('scroll');
41
+ if (this.scrollTimer !== undefined) this.cancel(this.scrollTimer);
42
+ this.scrollTimer = this.schedule(() => {
43
+ this.scrollTimer = undefined;
44
+ this.controller.resume('scroll');
45
+ }, 500);
46
+ this.scrollTimer?.unref?.();
47
+ };
48
+ card.addEventListener('wheel', pauseScroll);
49
+ card.addEventListener('scroll', pauseScroll);
50
+ card.addEventListener('pointerdown', () => this.controller.pause('pointer'));
51
+ card.addEventListener('pointerup', () => this.controller.resume('pointer'));
52
+ card.addEventListener('pointercancel', () => this.controller.resume('pointer'));
53
+ this.$('reply-retain').onclick = () => this.controller.pin();
54
+ this.$('reply-close').onclick = () => this.controller.close();
55
+ this.$('reply-history-open').onclick = () => this.controller.openHistory();
56
+ this.$('reply-return-latest').onclick = () => this.controller.returnLatest();
57
+ this.$('reply-retry').onclick = () => { if (this.value?.draft) this.retry(this.value.draft); };
58
+ }
59
+
60
+ render(value) {
61
+ this.value = value;
62
+ const compose = value.mode === 'compose' || value.mode === 'history';
63
+ const history = value.mode === 'history';
64
+ this.$('compose').hidden = !compose;
65
+ this.$('chat-history-header').hidden = !history;
66
+ this.$('chat-transcript').hidden = !history;
67
+ this.$('chat-scope').hidden = !history;
68
+ this.$('reply-sent').hidden = value.mode !== 'sent';
69
+ this.$('reply-card').hidden = value.mode !== 'reply';
70
+ this.$('reply-return-latest').hidden = !history || !value.hasNewer;
71
+
72
+ const retrying = value.mode === 'compose' && value.draft?.settlement === 'unconfirmed';
73
+ this.$('reply-retry').hidden = !retrying;
74
+ this.$('chat-status').textContent = value.draft ? draftStatus(value.draft.settlement)
75
+ : value.draftNotice ? draftStatus(value.draftNotice)
76
+ : value.blockedReason === 'permission_required' ? '聊天权限已撤销' : '';
77
+ if (value.draft) {
78
+ const input = this.$('message');
79
+ if (!input.value || input.value === this.lastRenderedDraft) input.value = value.draft.text;
80
+ this.lastRenderedDraft = value.draft.text;
81
+ }
82
+
83
+ if (value.letter) {
84
+ this.$('reply-letter-text').textContent = value.letter.text;
85
+ this.$('reply-input-text').textContent = value.letter.text;
86
+ this.$('reply-sent-status').textContent = phaseLabel(value.letter.responsePhase);
87
+ }
88
+ if (value.reply) {
89
+ this.$('reply-body').textContent = value.reply.text;
90
+ this.$('reply-body-status').textContent = value.reply.complete ? '' : '正在载入完整回复…';
91
+ } else {
92
+ this.$('reply-body').textContent = '';
93
+ this.$('reply-body-status').textContent = '';
94
+ }
95
+
96
+ const canRetain = value.mode === 'reply' && value.reply?.complete === true && !value.retained;
97
+ this.$('reply-retain').hidden = !canRetain;
98
+ this.$('reply-close').hidden = value.mode !== 'reply' || !value.retained;
99
+ this.$('reply-retain').dataset.running = String(canRetain && value.timer.running);
100
+ this.$('reply-retain').style.setProperty('--reply-visible-ms', `${value.timer.remainingMs}ms`);
101
+ this.$('chat').dataset.replyMode = value.mode;
102
+ }
103
+ }
@@ -94,8 +94,44 @@ button:disabled { opacity: .45; cursor: default; }
94
94
  .call-status-line { min-height: 24px; display: flex; align-items: center; gap: 7px; white-space: nowrap; }
95
95
  .call-status-line strong { font-size: 13px; font-weight: 600; }
96
96
  .call-status-line span:not(.status-dot) { color: var(--desktop-muted); font-size: 12px; }
97
- .status-dot { width: 8px; height: 8px; flex: none; border-radius: 999px; background: var(--desktop-success); }
98
- [data-connection-state="recovering"] .status-dot { background: var(--desktop-warning); }
97
+ .status-dot {
98
+ --call-status-color: var(--desktop-success);
99
+ position: relative;
100
+ isolation: isolate;
101
+ z-index: 0;
102
+ width: 8px;
103
+ height: 8px;
104
+ flex: none;
105
+ border-radius: 999px;
106
+ background: var(--call-status-color);
107
+ }
108
+ .status-dot::after {
109
+ content: "";
110
+ position: absolute;
111
+ inset: 0;
112
+ z-index: -1;
113
+ border-radius: inherit;
114
+ background: var(--call-status-color);
115
+ opacity: 0;
116
+ pointer-events: none;
117
+ transform: scale(1);
118
+ }
119
+ [data-connection-state="connected"] .status-dot::after,
120
+ [data-connection-state="recovering"] .status-dot::after { animation: call-status-breathe 2s ease-out infinite; }
121
+ [data-connection-state="recovering"] .status-dot { --call-status-color: var(--desktop-warning); }
122
+ [data-connection-state="connecting"] .status-dot,
123
+ [data-connection-state="idle"] .status-dot { --call-status-color: var(--desktop-muted); }
124
+ @keyframes call-status-breathe {
125
+ from { opacity: .38; transform: scale(1); }
126
+ to { opacity: 0; transform: scale(3); }
127
+ }
128
+ .call-meeting { min-width: 0; margin: 3px 0 0 15px; display: flex; align-items: center; gap: 5px; color: var(--desktop-muted); font-size: 12px; line-height: 1.35; }
129
+ .call-context > .call-meeting { grid-column: 1 / -1; margin: 0; }
130
+ .meeting-status-dot { width: 7px; height: 7px; flex: none; border-radius: 999px; background: var(--desktop-success); }
131
+ .call-meeting[data-tone="quiet"] .meeting-status-dot { background: var(--desktop-muted); }
132
+ .call-meeting[data-tone="warning"] .meeting-status-dot { background: var(--desktop-warning); }
133
+ .meeting-label { flex: none; white-space: nowrap; }
134
+ .meeting-source { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
99
135
  .call-work { margin: 3px 0 0 15px; color: var(--desktop-muted); font-size: 12px; }
100
136
  .call-share-context { display: flex; align-items: center; gap: 7px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--desktop-line); }
101
137
  .call-share-context > span, .call-share-line > span { display: grid; gap: 1px; min-width: 0; }
@@ -132,6 +168,7 @@ button:disabled { opacity: .45; cursor: default; }
132
168
  .call-context > small { grid-column: 2 / 4; }
133
169
  .call-share-line { grid-column: 1 / -1; display: flex; align-items: center; gap: 7px; padding-top: 6px; border-top: 1px solid var(--desktop-line); min-width: 0; }
134
170
  .call-context-actions { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
171
+ .call-context-inline-actions { grid-column: 4; grid-row: 1; }
135
172
  .call-context-actions button { display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 9px; white-space: nowrap; }
136
173
  #menu .call-context { margin: -8px -8px 5px; }
137
174
  .menu-footer { flex: none; display: flex; align-items: center; gap: 2px; margin: 4px -8px -8px; border-top: 1px solid var(--desktop-line); padding: 4px; }
@@ -150,17 +187,47 @@ button:disabled { opacity: .45; cursor: default; }
150
187
  .work-overview-row span { color: var(--desktop-muted); font-size: 10px; }
151
188
  #work-notice { margin: 0; padding: 3px 11px 7px; color: var(--desktop-muted); font-size: 10px; }
152
189
  #chat { width: 400px; max-height: 500px; display: flex; flex-direction: column; overflow: hidden; }
153
- #chat header { padding: 7px 9px 7px 15px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--cafe-border-subtle); }
154
- #chat header button { font-size: 12px; color: var(--cafe-text-secondary); }
190
+ #chat[data-reply-mode="compose"] { width: 340px; }
191
+ #chat[data-reply-mode="sent"] { width: 210px; background: transparent; box-shadow: none; }
192
+ #chat[data-reply-mode="reply"] { width: 370px; }
193
+ #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
+ #chat-history-header button { font-size: 12px; color: var(--cafe-text-secondary); }
155
195
  #chat-transcript { height: 230px; overflow: auto; padding: 14px; line-height: 1.7; font-size: 13px; }
156
196
  #chat-transcript p { margin: 0 0 10px; overflow-wrap: anywhere; white-space: pre-wrap; }
157
197
  #chat-transcript p:not(.empty) { width: fit-content; max-width: 92%; padding: 7px 10px; border-radius: 9px; }
158
198
  #chat-transcript p.user { margin-left: auto; background: var(--desktop-rail); }
159
199
  #chat-transcript p.assistant { margin-right: auto; background: var(--desktop-work); border: 1px solid var(--desktop-line); }
160
200
  .empty { color: var(--cafe-text-muted); font-size: 13px; }
161
- #compose { display: flex; align-items: flex-end; padding: 10px; gap: 8px; border-top: 1px solid var(--cafe-border-subtle); }
162
- #message { resize: none; border: 0; min-width: 0; flex: 1; background: transparent; padding: 5px; color: inherit; line-height: 1.5; }
163
- #chat small { color: var(--cafe-text-muted); font-size: 10px; padding: 0 14px 10px; }
201
+ #compose { display: flex; align-items: center; padding: 10px; gap: 8px; border-top: 1px solid var(--cafe-border-subtle); }
202
+ #chat[data-reply-mode="compose"] #compose { border: 0; }
203
+ #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; }
204
+ .chat-feedback { min-height: 0; display: flex; align-items: center; gap: 8px; padding: 0 12px 8px; color: #8f4730; }
205
+ .chat-feedback small { flex: 1; font-size: 11px; }
206
+ .chat-feedback button { color: #8f4730; font-size: 11px; }
207
+ #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; }
210
+ .reply-sent > svg { width: 36px; height: 27px; flex: none; overflow: visible; }
211
+ .reply-sent > svg rect { fill: var(--desktop-paper); stroke: var(--desktop-ink); stroke-width: 1.5; }
212
+ .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; }
216
+ .reply-card { position: relative; max-height: 500px; overflow-y: auto; padding: 12px 14px 10px; }
217
+ .reply-head { display: flex; align-items: flex-start; gap: 8px; }
218
+ .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); }
220
+ .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; }
224
+ .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; }
226
+ .reply-body { margin-top: 7px; color: var(--desktop-ink); font-size: 14px; line-height: 1.65; overflow-wrap: anywhere; white-space: pre-wrap; }
227
+ .reply-body-status { display: block; margin-top: 6px; color: var(--desktop-muted); font-size: 11px; }
228
+ .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; }
230
+ @keyframes reply-countdown { to { stroke-dashoffset: 40.84; } }
164
231
  .call-transcript { width: 420px; height: 210px; display: flex; flex-direction: column; overflow: hidden; }
165
232
  .call-transcript[data-expanded="true"] { height: 500px; }
166
233
  .transcript-call-context { flex: none; grid-template-columns: auto minmax(0, 1fr) auto; gap: 5px; padding: 7px 8px; }
@@ -238,7 +305,7 @@ button:disabled { opacity: .45; cursor: default; }
238
305
  .settings-empty { padding: 16px; }
239
306
  .sr-only { position: absolute; width: 1px; height: 1px; clip-path: inset(50%); overflow: hidden; }
240
307
  @media (prefers-reduced-motion: reduce) {
241
- #pet, #indicators button, #living-sit [data-layer], #pet[data-action="idle"] #living-sit [data-layer] { animation: none; }
308
+ #pet, #indicators button, [data-connection-state] .status-dot::after, .reply-ring[data-running="true"] .reply-ring-progress, #living-sit [data-layer], #pet[data-action="idle"] #living-sit [data-layer] { animation: none; }
242
309
  #living-sit [data-layer="closed"] { opacity: 0; }
243
310
  }
244
311
  #pet[data-skin="yanyan-codex"] { background-image: url("./skins/yanyan-codex.webp"); }