@clowder-ai/companion 0.1.0-alpha.20 → 0.1.0-alpha.24

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.
@@ -60,15 +60,17 @@ button:disabled { opacity: .45; cursor: default; }
60
60
  from { left: var(--living-left-start); top: var(--living-top-start); }
61
61
  to { left: var(--living-left-end); top: var(--living-top-end); }
62
62
  }
63
- #indicators { position: absolute; bottom: 0; right: 0; display: flex; gap: 4px; }
64
- #indicators button { width: 32px; height: 32px; border: 1px solid #fff9; border-radius: 50%; display: grid; place-items: center; background: #236b59; color: white; box-shadow: 0 2px 5px #0003; }
63
+ #indicators { position: absolute; inset: 0; pointer-events: none; }
64
+ #indicators button { position: absolute; width: 32px; height: 32px; pointer-events: auto; border: 1px solid #fff9; border-radius: 50%; display: grid; place-items: center; background: #236b59; color: white; box-shadow: 0 2px 5px #0003; }
65
65
  #indicators button:hover { background: #ab4842; }
66
66
  #indicators button[data-state="connecting"] { background: #a77b36; animation: pulse 1.8s infinite; }
67
67
  #indicators button[data-state="muted"] { background: #5f6670; }
68
68
  #indicators button[data-state="receive-only"] { background: #476dac; }
69
69
  #indicators svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }
70
- #share-badge { background: #476dac !important; }
71
- #pending-badge { width: auto !important; min-width: 32px; padding: 0 9px; border-radius: 999px !important; background: #9b6841 !important; font-size: 11px; font-weight: 700; white-space: nowrap; }
70
+ #share-badge { right: 0; bottom: 0; background: #476dac !important; }
71
+ #pending-badge { top: 0; right: 0; width: auto !important; min-width: 24px; height: 24px !important; padding: 0 7px; border: 2px solid white !important; border-radius: 999px !important; background: #f2dcc8 !important; color: #7a3f2e !important; font-size: 11px; font-weight: 700; white-space: nowrap; }
72
+ #pet[data-dock-edge="right"] + #indicators #pending-badge,
73
+ #pet[data-dock-edge="right"] + #indicators #share-badge { right: auto; left: 0; }
72
74
  @keyframes pulse { 50% { opacity: .65; } }
73
75
  .floating { position: absolute; z-index: 3; border-radius: 12px; border: 1px solid var(--desktop-line-strong); background: var(--desktop-paper); box-shadow: 0 14px 28px #18171526, 0 2px 5px #1817150f; }
74
76
  .floating { max-height: 500px; overflow: auto; }
@@ -83,7 +85,7 @@ button:disabled { opacity: .45; cursor: default; }
83
85
  .floating .destructive { background: var(--desktop-danger); color: white; font-weight: 500; }
84
86
  .floating .destructive:hover:not(:disabled) { background: #942c24; }
85
87
  .floating .destructive.compact { min-width: 72px; padding: 6px 12px; }
86
- #actions { padding: 12px; width: 364px; overflow: hidden; }
88
+ #actions { padding: 12px; width: 412px; overflow: hidden; }
87
89
  .action-row { display: flex; align-items: center; gap: 4px; }
88
90
  .action-row button { display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 35px; font-size: 12px; white-space: nowrap; }
89
91
  .action-row button .icon { width: 16px; height: 16px; }
@@ -92,8 +94,44 @@ button:disabled { opacity: .45; cursor: default; }
92
94
  .call-status-line { min-height: 24px; display: flex; align-items: center; gap: 7px; white-space: nowrap; }
93
95
  .call-status-line strong { font-size: 13px; font-weight: 600; }
94
96
  .call-status-line span:not(.status-dot) { color: var(--desktop-muted); font-size: 12px; }
95
- .status-dot { width: 8px; height: 8px; flex: none; border-radius: 999px; background: var(--desktop-success); }
96
- [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; }
97
135
  .call-work { margin: 3px 0 0 15px; color: var(--desktop-muted); font-size: 12px; }
98
136
  .call-share-context { display: flex; align-items: center; gap: 7px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--desktop-line); }
99
137
  .call-share-context > span, .call-share-line > span { display: grid; gap: 1px; min-width: 0; }
@@ -108,8 +146,11 @@ button:disabled { opacity: .45; cursor: default; }
108
146
  .recent-message-text { unicode-bidi: isolate; }
109
147
  .call-recent p[data-role="user"] { color: var(--desktop-muted); }
110
148
  .call-actions { justify-content: flex-end; }
149
+ .call-actions #mic .label, .call-actions [data-action="transcript"] > span { display: none; }
111
150
  .call-actions #end { min-width: 88px; margin-left: auto; }
112
- #menu { width: 340px; padding: 8px; }
151
+ .action-unread { min-width: 17px; height: 17px; display: inline-grid; place-items: center; border-radius: 999px; padding: 0 5px; background: #f2dcc8; color: #7a3f2e; font-size: 10px; font-weight: 700; line-height: 1; }
152
+ #menu { width: 340px; max-height: 500px; display: flex; flex-direction: column; overflow: hidden; padding: 8px; }
153
+ .menu-scroll { min-height: 0; flex: 1 1 auto; overflow-x: hidden; overflow-y: auto; }
113
154
  #menu .menu-title { display: block; padding: 7px 11px 6px; color: var(--cafe-text-muted); font-size: 10px; font-weight: 500; letter-spacing: .04em; }
114
155
  #menu button { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 33px; text-align: left; font-size: 12px; line-height: 1.4; }
115
156
  #menu button .icon { color: var(--cafe-text-secondary); width: 16px; height: 16px; }
@@ -127,8 +168,24 @@ button:disabled { opacity: .45; cursor: default; }
127
168
  .call-context > small { grid-column: 2 / 4; }
128
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; }
129
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; }
130
172
  .call-context-actions button { display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 9px; white-space: nowrap; }
131
173
  #menu .call-context { margin: -8px -8px 5px; }
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; }
175
+ #menu .menu-footer button { width: auto; min-width: 38px; min-height: 33px; padding: 7px 8px; }
176
+ #menu .menu-footer #menu-hide { margin-left: auto; }
177
+ .menu-call-actions { grid-column: 1 / -1; width: 100%; }
178
+ .menu-call-actions button { width: auto !important; min-width: 34px !important; }
179
+ .menu-call-actions button:last-child { margin-left: auto; }
180
+ .work-overview { margin: 3px -8px; border-top: 1px solid var(--desktop-line); border-bottom: 1px solid var(--desktop-line); }
181
+ .work-overview-section + .work-overview-section { border-top: 1px solid var(--desktop-line); }
182
+ .work-overview-section header { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 8px 11px 3px; color: var(--desktop-muted); font-size: 11px; }
183
+ .work-overview-section header small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; }
184
+ #menu .work-overview-row { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1px; min-height: 43px; border-radius: 0; padding: 6px 11px; }
185
+ .work-overview-row strong, .work-overview-row span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
186
+ .work-overview-row strong { color: var(--desktop-ink); font-size: 12px; font-weight: 500; }
187
+ .work-overview-row span { color: var(--desktop-muted); font-size: 10px; }
188
+ #work-notice { margin: 0; padding: 3px 11px 7px; color: var(--desktop-muted); font-size: 10px; }
132
189
  #chat { width: 400px; max-height: 500px; display: flex; flex-direction: column; overflow: hidden; }
133
190
  #chat header { padding: 7px 9px 7px 15px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--cafe-border-subtle); }
134
191
  #chat header button { font-size: 12px; color: var(--cafe-text-secondary); }
@@ -218,7 +275,7 @@ button:disabled { opacity: .45; cursor: default; }
218
275
  .settings-empty { padding: 16px; }
219
276
  .sr-only { position: absolute; width: 1px; height: 1px; clip-path: inset(50%); overflow: hidden; }
220
277
  @media (prefers-reduced-motion: reduce) {
221
- #pet, #indicators button, #living-sit [data-layer], #pet[data-action="idle"] #living-sit [data-layer] { animation: none; }
278
+ #pet, #indicators button, [data-connection-state] .status-dot::after, #living-sit [data-layer], #pet[data-action="idle"] #living-sit [data-layer] { animation: none; }
222
279
  #living-sit [data-layer="closed"] { opacity: 0; }
223
280
  }
224
281
  #pet[data-skin="yanyan-codex"] { background-image: url("./skins/yanyan-codex.webp"); }
@@ -11,10 +11,15 @@ import { PetMotion } from './pet-motion.mjs';
11
11
  import { LivingBody } from './living-body.mjs';
12
12
  import { detectDockedEdge } from './living-edge.mjs';
13
13
  import { bindPetControls } from './pet-controls.mjs';
14
- import { decisionBadge, decisionPresentation, decisionRows } from './decision-view.mjs';
14
+ import { DecisionPanel } from './decision-panel.mjs';
15
15
  import { nativeWorkLabel, normalizeNativeWork } from './native-work-motion.mjs';
16
16
  import { NativeWindowTravel } from './native-window-travel.mjs';
17
17
  import { currentCompanionIdentity } from './companion-identity.mjs';
18
+ import { FoldedChatUnread } from './chat-unread.mjs';
19
+ import { COMPACT_BALL_SIZE, compactSizeAction } from './compact-size.mjs';
20
+ import { WorkOverviewView } from './work-overview.mjs';
21
+ import { MeetingStatus, renderMeetingStatus } from './meeting-state.mjs';
22
+ import { VisibleTextPresentation } from './visible-text-receipt.mjs';
18
23
  const $ = id => document.getElementById(id);
19
24
  const label = (id, text) => { $(id).querySelector('.label').textContent = text; };
20
25
  const status = text => {
@@ -32,6 +37,12 @@ const callTranscript = new CallTranscriptView($('call-transcript-log'), {
32
37
  });
33
38
  const bubble = new RecentBubble([$('bubble-first'), $('bubble-second')]);
34
39
  const callBubble = new RecentBubble([$('call-bubble-first'), $('call-bubble-second')]);
40
+ const meetingStatus = new MeetingStatus();
41
+ const foldedChatUnread = new FoldedChatUnread(count => {
42
+ $('call-chat-unread').hidden = count === 0;
43
+ $('call-chat-unread').textContent = count ? String(count) : '';
44
+ $('call-chat').setAttribute('aria-label', count ? `聊天,${count} 条未读` : '聊天');
45
+ });
35
46
  const motion = new PetMotion($('pet'), { livingBody: new LivingBody({ root: $('pet'), sit: $('living-sit'),
36
47
  video: $('living-video'), transitionVideo: $('living-video-transition') }) });
37
48
  const nativeTravel = new NativeWindowTravel({
@@ -39,15 +50,12 @@ const nativeTravel = new NativeWindowTravel({
39
50
  changed: value => motion.syncTravel(value),
40
51
  });
41
52
  let sharing = false, pendingScreen = false, loading = false, transcriptLoading = false, latestHistory, previousPhase = 'idle';
42
- let didMove = false, decisionLoading = false, decisionOffset = 0, threadTitle, settingsView, settingsValues, latestDecisionPage;
53
+ let didMove = false, threadTitle, settingsView, settingsValues, decisionPanel, workView;
54
+ let previousControlPanel = 'none';
43
55
  // F229 stays quiet: a history ID change has no provenance for proactive text.
44
56
  const ambientPanel = active => active ? 'actions' : 'none';
45
57
  const setTexts = (ids, text) => { for (const id of ids) $(id).textContent = text; };
46
58
  const setHidden = (ids, hidden) => { for (const id of ids) $(id).hidden = hidden; };
47
- const decisionReadBasis = page => page?.version === 1
48
- ? [page.status, page.totalCount ?? 'unknown', ...['approvals', 'needsMe']
49
- .flatMap(key => [page.sources?.[key]?.status, page.sources?.[key]?.coverage])].join('|')
50
- : page ? 'legacy' : null;
51
59
  if (!window.clowderCompanion) {
52
60
  $('actions').hidden = false; $('status').hidden = false;
53
61
  status('请从 Clowder 的聊聊入口打开猫猫球');
@@ -55,6 +63,11 @@ if (!window.clowderCompanion) {
55
63
  } else {
56
64
  nativeTravel.start();
57
65
  const client = createCompanionClient(window.clowderCompanion);
66
+ const visibleText = new VisibleTextPresentation({
67
+ document,
68
+ report: observation => client.observePresentation(observation),
69
+ transcript, callTranscript, bubble, callBubble,
70
+ });
58
71
  const controls = bindPetControls(client, {
59
72
  error: (error, context) => status(context?.panel === 'settings' && error?.code === 'invalid_request' ? '这个 Host 版本还不支持猫旁设置。原来的设置入口会继续保留。' : explainError(error)),
60
73
  moved: (dx, dy) => {
@@ -67,10 +80,15 @@ if (!window.clowderCompanion) {
67
80
  setTimeout(() => motion.setDockedEdge(detectDockedEdge(window, $('pet').getBoundingClientRect())), 100);
68
81
  },
69
82
  changed: panel => {
83
+ visibleText.retry();
84
+ if (panel === 'chat') foldedChatUnread.open();
85
+ else if (previousControlPanel === 'chat' && conversation?.active) foldedChatUnread.fold();
86
+ previousControlPanel = panel;
70
87
  if (panel === 'chat') void readHistory();
71
88
  if (panel === 'transcript') void readCallTranscript();
72
- if (panel === 'decisions') void readDecisions();
89
+ if (panel === 'decisions') void decisionPanel?.read();
73
90
  if (panel === 'settings') void settingsView?.open();
91
+ if (panel === 'menu') void workView?.load();
74
92
  },
75
93
  action(kind) {
76
94
  if (kind === 'begin') { transcript.reset(); callTranscript.reset(); bubble.reset(); callBubble.clear(); void conversation.begin(); void controls.show('none'); }
@@ -81,6 +99,10 @@ if (!window.clowderCompanion) {
81
99
  if (kind === 'share') toggleScreen();
82
100
  if (kind === 'documents') void conversation.documents(conversation.identity?.documentsAllowed === false);
83
101
  if (kind === 'hide') void client.hide().catch(error => status(explainError(error)));
102
+ if (kind === 'size-toggle') {
103
+ if (!settingsValues) { status('大小设置仍在读取'); return; }
104
+ void settingsView.controller.requestUpdate('ballSize', compactSizeAction(settingsValues.ballSize).value);
105
+ }
84
106
  if (kind === 'transcript-return') void controls.show('actions');
85
107
  if (kind === 'transcript-expand') {
86
108
  const expanded = $('transcript').dataset.expanded !== 'true';
@@ -103,7 +125,7 @@ if (!window.clowderCompanion) {
103
125
  }, value => {
104
126
  const wasActive = sharing || pendingScreen;
105
127
  sharing = value.sharing; pendingScreen = value.pending === true;
106
- for (const id of ['share', 'menu-share', 'chat-share', 'settings-share']) label(id, sharing ? '停止共享' : pendingScreen ? '取消选屏' : '共享画面');
128
+ for (const id of ['share', 'menu-share', 'menu-call-share', 'chat-share', 'settings-share']) label(id, sharing ? '停止共享' : pendingScreen ? '取消选屏' : '共享画面');
107
129
  for (const prefix of ['call', 'menu', 'chat', 'transcript', 'settings']) {
108
130
  $(`${prefix}-share-context`).hidden = !sharing;
109
131
  $(`${prefix}-share-target`).textContent = sharing ? `共享中:${value.label}` : '';
@@ -132,10 +154,8 @@ if (!window.clowderCompanion) {
132
154
  callTranscript.appendTyped(event);
133
155
  void readHistory(); void readCallTranscript();
134
156
  } else {
135
- transcript.append(event.role, event.text, true);
136
- callTranscript.appendLive(event);
137
157
  const speaker = event.role === 'assistant' ? conversation.identity?.displayName ?? '猫猫' : '语音';
138
- bubble.append(event.role, event.text, speaker); callBubble.append(event.role, event.text, speaker);
158
+ visibleText.appendLive(event, speaker);
139
159
  void controls.setAmbient('actions');
140
160
  }
141
161
  },
@@ -144,6 +164,7 @@ if (!window.clowderCompanion) {
144
164
  const justConnected = previousPhase !== 'talking' && value.phase === 'talking';
145
165
  if (previousPhase !== 'idle' && value.phase === 'idle') {
146
166
  transcript.reset(); callTranscript.reset(); bubble.reset(); callBubble.clear(); void readHistory();
167
+ foldedChatUnread.reset();
147
168
  if (controls.panel === 'transcript') void controls.show('menu');
148
169
  }
149
170
  previousPhase = value.phase;
@@ -151,7 +172,7 @@ if (!window.clowderCompanion) {
151
172
  const receiveOnly = value.audioMode === 'receive_only';
152
173
  const canReceiveOnly = value.identity?.audio?.supportedModes?.includes('receive_only') === true;
153
174
  const retryReceiveOnly = value.failed && value.retryMode === 'receive_only' && canReceiveOnly;
154
- callTranscript.setCall(value.callId);
175
+ visibleText.setCall(value.callId);
155
176
  void controls.setAmbient(ambientPanel(active));
156
177
  $('begin').hidden = active; $('begin').disabled = !value.identity;
157
178
  $('listen').hidden = active || !canReceiveOnly; $('listen').disabled = !value.identity;
@@ -161,10 +182,12 @@ if (!window.clowderCompanion) {
161
182
  $('listen').className = retryReceiveOnly ? 'primary' : '';
162
183
  $('mic').hidden = value.phase !== 'talking' || receiveOnly; $('speaker').hidden = !active;
163
184
  $('chat-mic').hidden = value.phase !== 'talking' || receiveOnly;
185
+ $('menu-mic').hidden = value.phase !== 'talking' || receiveOnly;
164
186
  $('transcript-mic').hidden = value.phase !== 'talking' || receiveOnly;
165
187
  $('settings-mic').hidden = value.phase !== 'talking' || receiveOnly;
166
188
  $('active-actions').hidden = !active;
167
189
  $('share').disabled = value.phase !== 'talking';
190
+ $('menu-call-share').disabled = value.phase !== 'talking';
168
191
  $('compose').querySelector('button').disabled = !value.identity || value.phase === 'connecting';
169
192
  status(value.message ?? '');
170
193
  $('status').hidden = !value.message || /^(点击|正在听|只听模式|只听已结束|语音通话已结束|已发送)/.test(value.message);
@@ -175,20 +198,31 @@ if (!window.clowderCompanion) {
175
198
  : value.phase === 'connecting' ? '正在请求麦克风'
176
199
  : value.recovering ? `正在等待原连接恢复 · ${value.muted ? '麦克风已静音' : '麦克风已开启'}`
177
200
  : value.muted ? '麦克风已静音' : active ? '麦克风已开启' : '';
178
- 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';
201
+ const connectionState = value.recovering ? 'recovering' : value.phase === 'talking' ? 'connected' : value.phase === 'connecting' ? 'connecting' : 'idle';
202
+ for (const id of ['actions', 'chat-call-context', 'transcript-call-context', 'menu-call-context', 'settings-call-context']) $(id).dataset.connectionState = connectionState;
179
203
  setTexts(['call-state', 'chat-call-state', 'transcript-call-state', 'menu-call-state', 'settings-call-state'], callState);
180
204
  setTexts(['call-mic-state', 'chat-call-mic-state', 'transcript-call-mic-state', 'menu-call-mic-state', 'settings-call-mic-state'], microphoneState);
181
205
  setHidden(['chat-call-context', 'transcript-call-context', 'menu-call-context', 'settings-call-context'], !active);
182
206
  label('mic', value.muted ? '取消静音' : '静音'); $('mic').setAttribute('aria-pressed', String(value.muted));
207
+ $('mic').setAttribute('aria-label', value.muted ? '取消静音' : '静音');
183
208
  $('mic-icon').setAttribute('href', value.muted ? '#i-mic-off' : '#i-mic');
184
209
  $('chat-mic').textContent = value.muted ? '取消静音' : '静音';
210
+ $('menu-mic').setAttribute('aria-label', value.muted ? '取消静音' : '静音');
211
+ $('menu-mic').title = value.muted ? '取消静音' : '静音';
212
+ $('menu-mic').setAttribute('aria-pressed', String(value.muted));
213
+ $('menu-mic-icon').setAttribute('href', value.muted ? '#i-mic-off' : '#i-mic');
185
214
  $('settings-mic').textContent = value.muted ? '取消静音' : '静音';
186
215
  $('transcript-mic').setAttribute('aria-label', value.muted ? '取消静音' : '静音');
187
216
  $('transcript-mic').setAttribute('aria-pressed', String(value.muted));
188
217
  $('transcript-mic-icon').setAttribute('href', value.muted ? '#i-mic-off' : '#i-mic');
189
- label('speaker', value.silent ? '开启播音' : '关闭播音');
218
+ label('speaker', '播音');
219
+ $('speaker').setAttribute('aria-pressed', String(!value.silent));
220
+ $('speaker').title = value.silent ? '开启播音' : '关闭播音';
221
+ $('name-card-actions').hidden = active;
222
+ $('menu-hide-label').textContent = active ? '结束通话并隐藏' : '隐藏';
190
223
  const identity = value.identity;
191
224
  const companionIdentity = currentCompanionIdentity(identity);
225
+ renderMeetingStatus(document, meetingStatus.update(active ? identity?.meeting : null));
192
226
  const nativeSnapshot = normalizeNativeWork(identity?.nativeWork);
193
227
  const workLabel = nativeWorkLabel(nativeSnapshot, identity?.nativeActivity);
194
228
  const workState = workLabel ? `${identity.displayName}${workLabel}` : '';
@@ -231,14 +265,30 @@ if (!window.clowderCompanion) {
231
265
  settleExpectedHostStop: callStopped => conversation.settleExpectedHostStop(callStopped),
232
266
  layoutChanged: () => { if (controls.panel === 'settings') controls.remeasure?.(); },
233
267
  onValues(values) {
268
+ const sizeChanged = settingsValues !== undefined && settingsValues.ballSize !== values.ballSize;
234
269
  settingsValues = values;
235
270
  document.documentElement?.style?.setProperty?.('--pet-scale', String(values.ballSize / 72));
236
271
  $('anchor').dataset.ballSize = String(values.ballSize);
272
+ $('anchor').dataset.compact = String(values.ballSize <= COMPACT_BALL_SIZE);
273
+ label('menu-size-toggle', compactSizeAction(values.ballSize).label);
237
274
  motion.setBehaviorEnabled(values.behaviorEnabled);
238
275
  $('documents').hidden = true;
239
- if (latestDecisionPage) showDecisionBadge(latestDecisionPage);
276
+ decisionPanel?.refreshBadge();
277
+ if (sizeChanged) void controls.show(controls.panel);
240
278
  },
241
279
  });
280
+ workView = new WorkOverviewView({
281
+ document,
282
+ client,
283
+ changed: () => { if (controls.panel === 'menu') controls.remeasure?.(); },
284
+ });
285
+ decisionPanel = new DecisionPanel({
286
+ document,
287
+ client,
288
+ controls,
289
+ motion,
290
+ proactivePolicy: () => settingsValues?.proactivePolicy,
291
+ });
242
292
  async function readHistory() {
243
293
  if (loading) return;
244
294
  loading = true;
@@ -261,101 +311,14 @@ if (!window.clowderCompanion) {
261
311
  try {
262
312
  const reply = await client.readTranscript();
263
313
  if (!conversation.active || conversation.callId !== callId) return;
264
- callTranscript.load(reply);
265
- callBubble.loadTranscript(reply.rows, conversation.identity?.displayName);
314
+ if (!visibleText.loadTranscript(callId, reply, conversation.identity?.displayName)) return;
315
+ foldedChatUnread.receiveMany(reply.rows);
266
316
  $('call-transcript-status').textContent = reply.hasMore ? '这里只显示这次通话最近的内容' : '';
267
317
  } catch {
268
318
  if (conversation.active && conversation.callId === callId) $('call-transcript-status').textContent = '字幕暂未更新 · 实时内容仍会继续显示';
269
319
  } finally { transcriptLoading = false; }
270
320
  }
271
- function showDecisionBadge(page) {
272
- latestDecisionPage = page;
273
- const badge = decisionBadge(page);
274
- $('pending-badge').hidden = !badge.visible || settingsValues?.proactivePolicy !== 'quiet-badge';
275
- $('pending-count').textContent = badge.label;
276
- $('pending-badge').title = badge.title;
277
- $('pending-badge').setAttribute('aria-label', badge.title);
278
- $('menu-pending').textContent = badge.label;
279
- $('menu-pending').dataset.state = badge.state;
280
- }
281
- async function readDecisions(more = false) {
282
- if (decisionLoading) return;
283
- decisionLoading = true;
284
- if (!more && controls.panel === 'decisions') $('decision-status').textContent = '正在读取待办…';
285
- try {
286
- const previousReadBasis = decisionReadBasis(latestDecisionPage);
287
- const page = await client.readDecisions(more ? decisionOffset : 0, 10);
288
- const presentation = decisionPresentation(page);
289
- const changedRead = more && presentation.renderRows && previousReadBasis !== null
290
- && decisionReadBasis(page) !== previousReadBasis;
291
- showDecisionBadge(page);
292
- motion.setPendingDecision(presentation.pending);
293
- if (controls.panel !== 'decisions') return;
294
- const list = $('decision-list');
295
- if (!more || changedRead) { list.replaceChildren(); list.scrollTop = 0; }
296
- const retainedRows = more && !presentation.renderRows && list.children.length > 0;
297
- const knownVariantRefs = new Set(Array.from(list.children)
298
- .map(item => item.dataset.variantRef).filter(Boolean));
299
- for (const row of presentation.renderRows && !changedRead ? decisionRows(page) : []) {
300
- if (row.variantRef && knownVariantRefs.has(row.variantRef)) continue;
301
- const item = document.createElement('li');
302
- if (row.variantRef) { item.dataset.variantRef = row.variantRef; knownVariantRefs.add(row.variantRef); }
303
- const title = document.createElement('strong');
304
- const meta = document.createElement('span');
305
- title.textContent = row.title; meta.textContent = row.meta;
306
- item.append(title, meta); list.append(item);
307
- if (row.navigation) {
308
- const button = document.createElement('button');
309
- button.type = 'button'; button.textContent = row.navigation.label;
310
- button.onclick = async () => {
311
- try {
312
- const result = await client.openDecision(row.navigation.variantRef, row.navigation.target);
313
- $('decision-status').textContent = result.delivery === 'requested'
314
- ? '已请求打开对应事项' : '打开对应事项尚未确认 · 请从 Cat Café 查看';
315
- } catch { $('decision-status').textContent = '暂时无法打开对应事项 · 请稍后重试'; }
316
- };
317
- item.append(button);
318
- } else if (row.previewable) {
319
- const button = document.createElement('button');
320
- button.type = 'button'; button.textContent = '查看确认演练';
321
- button.onclick = async () => {
322
- try {
323
- const result = await client.inspectF221(row.proposalId);
324
- $('decision-status').textContent = result.status === 'trial_confirmed'
325
- ? '确认演练已完成;没有写回提案'
326
- : result.status === 'stale' ? '提案已变化,请重新查看原处卡片'
327
- : result.status === 'dismissed' ? '已取消演练;提案没有变化'
328
- : '确认演练暂不可用;请在原处处理';
329
- } catch { $('decision-status').textContent = '确认演练暂不可用;请在原处处理'; }
330
- };
331
- item.append(button);
332
- }
333
- }
334
- decisionOffset = changedRead ? 0 : page.page.offset + page.page.limit;
335
- $('decision-reload').textContent = changedRead ? '刷新' : presentation.reloadLabel;
336
- $('decision-status').textContent = changedRead && page.totalCount !== 0
337
- ? '待办已变化 · 请刷新查看最新列表'
338
- : retainedRows
339
- ? `${presentation.badge.state === 'authentication' ? '待办需要登录' : '待办暂不可读'} · 以下为上次读到的内容`
340
- : presentation.message;
341
- $('decision-more').hidden = changedRead || !presentation.hasMore;
342
- } catch {
343
- showDecisionBadge(undefined);
344
- motion.setPendingDecision(null);
345
- if (controls.panel === 'decisions') {
346
- const retained = $('decision-list').children.length > 0;
347
- $('decision-status').textContent = retained
348
- ? '待办暂不可读 · 以下为上次读到的内容'
349
- : '待办暂不可读 · 请稍后刷新';
350
- $('decision-reload').textContent = '重试';
351
- $('decision-more').hidden = true;
352
- }
353
- } finally { decisionLoading = false; }
354
- }
355
321
  $('share-badge').onclick = toggleScreen;
356
- $('pending-badge').onclick = () => void controls.show('decisions');
357
- $('decision-reload').onclick = () => void readDecisions();
358
- $('decision-more').onclick = () => void readDecisions(true);
359
322
  $('history').onclick = () => void client.openConversation().then(result => {
360
323
  if (result.delivery === 'unconfirmed') status('打开聊天尚未确认 · 请从 Clowder 查看');
361
324
  }).catch(error => status(explainError(error)));
@@ -377,10 +340,11 @@ if (!window.clowderCompanion) {
377
340
  const statusMonitor = setInterval(() => void conversation.refresh(), 1000);
378
341
  const historyMonitor = setInterval(() => void readHistory(), 3000);
379
342
  const transcriptMonitor = setInterval(() => void readCallTranscript(), 1000);
380
- const decisionMonitor = setInterval(() => { if (controls.panel !== 'decisions') void readDecisions(); }, 15000);
381
- void conversation.refresh(); void readHistory(); void readDecisions(); void settingsView.controller.load(); void controls.show('none');
343
+ const decisionMonitor = setInterval(() => { if (controls.panel !== 'decisions') void decisionPanel.read(); }, 15000);
344
+ const workMonitor = setInterval(() => { if (controls.panel === 'menu') void workView.load(); }, 15000);
345
+ void conversation.refresh(); void readHistory(); void decisionPanel.read(); void settingsView.controller.load(); void controls.show('none');
382
346
  window.addEventListener('beforeunload', () => {
383
- clearInterval(statusMonitor); clearInterval(historyMonitor); clearInterval(transcriptMonitor); clearInterval(decisionMonitor);
384
- unsubscribe(); nativeTravel.close(); motion.close(); void conversation.end();
347
+ clearInterval(statusMonitor); clearInterval(historyMonitor); clearInterval(transcriptMonitor); clearInterval(decisionMonitor); clearInterval(workMonitor);
348
+ unsubscribe(); visibleText.close(); nativeTravel.close(); motion.close(); void conversation.end();
385
349
  });
386
350
  }
@@ -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;