@clowder-ai/companion 0.1.0-alpha.26 → 0.1.0-alpha.28

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.26",
3
+ "version": "0.1.0-alpha.28",
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-l4CrAxOELZjC8DrB8BYv8K2pFg1ggCCgarOXhD/tfZ8="
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.26",
3
+ "version": "0.1.0-alpha.28",
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.26",
3
+ "version": "0.1.0-alpha.28",
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-l4CrAxOELZjC8DrB8BYv8K2pFg1ggCCgarOXhD/tfZ8="
44
44
  },
45
45
  "presentation": {
46
46
  "width": 300,
@@ -18,6 +18,7 @@ export class ConversationSurface {
18
18
  this.pendingOlderRead = false;
19
19
  this.bodyReads = new Set();
20
20
  this.replyMode = 'idle';
21
+ this.historySelection = null;
21
22
  this.controller = new ReplyLetterController({
22
23
  requestContinuation: value => void this.readBody(value.messageId, value.continuation),
23
24
  render: value => this.renderReply(value),
@@ -59,14 +60,27 @@ export class ConversationSurface {
59
60
  void this.readOlderHistory();
60
61
  }
61
62
  });
63
+ this.$('chat-transcript').addEventListener('wheel', event => {
64
+ if (event.deltaY < 0 && this.controller.mode === 'history'
65
+ && this.$('chat-transcript').scrollTop <= 0) void this.readOlderHistory();
66
+ }, { passive: true });
62
67
  this.$('compose').onsubmit = event => this.submit(event);
63
68
  }
64
69
 
65
70
  renderReply(value) {
66
71
  const previous = this.replyMode;
72
+ const previousSelection = this.historySelection;
73
+ if (previous === 'history' && value.mode !== 'history') {
74
+ this.transcript.leaveHistory(previousSelection);
75
+ }
67
76
  this.replyMode = value.mode;
68
77
  this.view.render(value);
69
78
  this.controls.remeasure?.();
79
+ if (value.mode === 'history'
80
+ && (previous !== 'history' || previousSelection !== value.selectedMessageId)) {
81
+ this.transcript.enterHistory(value.selectedMessageId);
82
+ }
83
+ this.historySelection = value.mode === 'history' ? value.selectedMessageId : null;
70
84
  if (value.mode === 'reply' && previous !== 'reply' && this.controls.panel !== 'chat') {
71
85
  void this.controls.show('chat');
72
86
  }
@@ -118,26 +118,35 @@
118
118
  <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
119
  <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
120
  </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>
121
+ <header id="chat-history-header"><strong id="chat-name">和猫猫写几句</strong>
122
+ <button id="chat-history-dismiss" data-action="dismiss" aria-label="收起文字">收起</button>
123
+ </header>
124
+ <div id="chat-transcript-shell" class="chat-transcript-shell" hidden>
125
+ <div id="chat-transcript" role="log" aria-live="polite"><p class="empty">想说什么,就写在这里。</p></div>
126
+ <button id="reply-history-close" class="reply-history-close" data-action="dismiss" aria-label="关闭历史" title="关闭历史" hidden>
127
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4 4 8 8m0-8-8 8"/></svg>
128
+ </button>
129
+ <button id="reply-return-latest" class="reply-return-latest" type="button" hidden>回到最新 ↓</button>
130
+ </div>
124
131
  <div id="reply-sent" class="reply-sent" tabindex="-1" hidden>
125
132
  <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>
133
+ <span><strong id="reply-sent-status">已发送</strong><small id="reply-letter-text" hidden></small></span>
127
134
  </div>
128
135
  <article id="reply-card" class="reply-card" hidden tabindex="-1">
129
136
  <div class="reply-head"><span id="reply-input-text" class="reply-input-text"></span>
130
137
  <button id="reply-retain" class="reply-ring" type="button" aria-label="留住回复" title="留住回复">
131
138
  <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
139
  </button>
133
- <button id="reply-close" class="reply-close" type="button" aria-label="关闭回复" title="关闭回复" hidden>×</button>
140
+ <button id="reply-close" class="reply-close" type="button" aria-label="关闭回复" title="关闭回复" hidden>
141
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4 4 8 8m0-8-8 8"/></svg>
142
+ </button>
134
143
  </div>
135
144
  <div id="reply-body" class="reply-body"></div>
136
145
  <small id="reply-body-status" class="reply-body-status" role="status"></small>
137
146
  <button id="reply-history-open" class="reply-history-open" type="button">往上翻聊天记录</button>
138
147
  </article>
139
148
  <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>
149
+ <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
150
  <small id="chat-connection-status" role="status"></small>
142
151
  <small id="chat-scope"><span id="connection-scope">语音与文字共用同一段聊天</span> <button id="history">在 Cat Café 中查看完整聊天 ↗</button></small>
143
152
  </section>
@@ -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
  }
@@ -188,11 +188,16 @@ button:disabled { opacity: .45; cursor: default; }
188
188
  #work-notice { margin: 0; padding: 3px 11px 7px; color: var(--desktop-muted); font-size: 10px; }
189
189
  #chat { width: 400px; max-height: 500px; display: flex; flex-direction: column; overflow: hidden; }
190
190
  #chat[data-reply-mode="compose"] { width: 340px; }
191
- #chat[data-reply-mode="sent"] { width: 210px; background: transparent; box-shadow: none; }
191
+ #chat[data-reply-mode="sent"] { width: max-content; border: 0; background: transparent; box-shadow: none; overflow: visible; }
192
192
  #chat[data-reply-mode="reply"] { width: 370px; }
193
+ #chat[data-reply-mode="history"][data-history-presentation="reply"] { width: 370px; }
193
194
  #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
195
  #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; }
196
+ .chat-transcript-shell { position: relative; min-height: 0; flex: 1 1 230px; }
197
+ #chat[data-history-presentation="reply"] .chat-transcript-shell { flex-basis: 480px; }
198
+ #chat-transcript { height: 100%; overflow: auto; padding: 14px; line-height: 1.7; font-size: 13px; }
199
+ .chat-transcript-shell[data-has-newer="true"] #chat-transcript { padding-bottom: 56px; }
200
+ #chat[data-history-presentation="reply"] #chat-transcript { padding-top: 44px; }
196
201
  #chat-transcript p { margin: 0 0 10px; overflow-wrap: anywhere; white-space: pre-wrap; }
197
202
  #chat-transcript p:not(.empty) { width: fit-content; max-width: 92%; padding: 7px 10px; border-radius: 9px; }
198
203
  #chat-transcript p.user { margin-left: auto; background: var(--desktop-rail); }
@@ -201,32 +206,35 @@ button:disabled { opacity: .45; cursor: default; }
201
206
  #compose { display: flex; align-items: center; padding: 10px; gap: 8px; border-top: 1px solid var(--cafe-border-subtle); }
202
207
  #chat[data-reply-mode="compose"] #compose { border: 0; }
203
208
  #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; }
209
+ #message:focus-visible { border-color: transparent; outline: 2px solid var(--cafe-accent); outline-offset: -2px; }
204
210
  .chat-feedback { min-height: 0; display: flex; align-items: center; gap: 8px; padding: 0 12px 8px; color: #8f4730; }
205
211
  .chat-feedback small { flex: 1; font-size: 11px; }
206
212
  .chat-feedback button { color: #8f4730; font-size: 11px; }
207
213
  #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; }
214
+ #chat-connection-status:empty { display: none; }
215
+ .reply-sent { align-items: center; gap: 5px; background: transparent; padding: 0; }
216
+ .reply-sent:not([hidden]) { display: flex; flex-direction: column; }
210
217
  .reply-sent > svg { width: 36px; height: 27px; flex: none; overflow: visible; }
211
218
  .reply-sent > svg rect { fill: var(--desktop-paper); stroke: var(--desktop-ink); stroke-width: 1.5; }
212
219
  .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; }
220
+ .reply-sent > span { min-width: 0; }
221
+ .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; }
222
+ .reply-sent small { display: none; }
216
223
  .reply-card { position: relative; max-height: 500px; overflow-y: auto; padding: 12px 14px 10px; }
217
224
  .reply-head { display: flex; align-items: flex-start; gap: 8px; }
218
225
  .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); }
226
+ #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
227
  .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; }
228
+ .reply-ring circle { fill: none; stroke-width: 1; }
229
+ .reply-ring-track { stroke: var(--desktop-line); }
230
+ .reply-ring-progress { stroke: var(--desktop-muted); stroke-linecap: round; stroke-dasharray: 40.84; stroke-dashoffset: 0; }
224
231
  .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; }
232
+ .reply-close svg, .reply-history-close svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; }
233
+ .reply-history-close { position: absolute; top: 10px; right: 10px; z-index: 1; background: var(--desktop-rail); }
226
234
  .reply-body { margin-top: 7px; color: var(--desktop-ink); font-size: 14px; line-height: 1.65; overflow-wrap: anywhere; white-space: pre-wrap; }
227
235
  .reply-body-status { display: block; margin-top: 6px; color: var(--desktop-muted); font-size: 11px; }
228
236
  .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; }
237
+ .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
238
  @keyframes reply-countdown { to { stroke-dashoffset: 40.84; } }
231
239
  .call-transcript { width: 420px; height: 210px; display: flex; flex-direction: column; overflow: hidden; }
232
240
  .call-transcript[data-expanded="true"] { height: 500px; }
@@ -6,6 +6,8 @@ export class TranscriptView {
6
6
  this.activeRows = new Map();
7
7
  this.historyRows = new Map();
8
8
  this.completed = new Set();
9
+ this.savedHistoryViewport = null;
10
+ this.pendingHistoryEntry = null;
9
11
  }
10
12
  append(role, text, chunk = false) {
11
13
  const pinned = this.nearBottom();
@@ -33,7 +35,8 @@ export class TranscriptView {
33
35
  if (pinned) this.container.scrollTop = this.container.scrollHeight;
34
36
  }
35
37
  nearBottom() {
36
- return this.container.scrollHeight - this.container.scrollTop - this.container.clientHeight < 32;
38
+ return !this.container.hidden
39
+ && this.container.scrollHeight - this.container.scrollTop - this.container.clientHeight < 32;
37
40
  }
38
41
  visibleHistoryAnchor() {
39
42
  const viewport = this.container.getBoundingClientRect?.();
@@ -45,15 +48,75 @@ export class TranscriptView {
45
48
  }
46
49
  return null;
47
50
  }
51
+ restoreHistoryAnchor(anchor, readingPosition, useFirstAvailable = false) {
52
+ const row = this.historyRows.get(anchor?.id)
53
+ ?? (useFirstAvailable ? this.historyRows.values().next().value : null);
54
+ const rect = row?.getBoundingClientRect?.();
55
+ const viewport = this.container.getBoundingClientRect?.();
56
+ if (!rect || !viewport || viewport.bottom <= viewport.top) {
57
+ this.container.scrollTop = readingPosition;
58
+ return false;
59
+ }
60
+ this.container.scrollTop += rect.top - viewport.top - anchor.offset;
61
+ return true;
62
+ }
63
+ revealHistoryRow(id) {
64
+ const row = this.historyRows.get(id);
65
+ const rect = row?.getBoundingClientRect?.();
66
+ const viewport = this.container.getBoundingClientRect?.();
67
+ if (!rect || !viewport || viewport.bottom <= viewport.top) return false;
68
+ if (rect.top >= viewport.top && rect.bottom <= viewport.bottom) return true;
69
+ const viewportHeight = viewport.bottom - viewport.top;
70
+ const rowHeight = Math.max(0, rect.bottom - rect.top);
71
+ const offset = Math.max(0, (viewportHeight - rowHeight) / 2);
72
+ this.container.scrollTop += rect.top - viewport.top - offset;
73
+ return true;
74
+ }
75
+ placeHistoryEntry(afterLoad) {
76
+ if (!this.pendingHistoryEntry || this.container.hidden) return false;
77
+ const viewport = this.pendingHistoryEntry;
78
+ if (viewport.anchor && this.restoreHistoryAnchor(viewport.anchor, viewport.scrollTop)) {
79
+ this.pendingHistoryEntry = null;
80
+ return true;
81
+ }
82
+ if (viewport.selectedMessageId && this.revealHistoryRow(viewport.selectedMessageId)) {
83
+ this.pendingHistoryEntry = null;
84
+ return true;
85
+ }
86
+ const bounds = this.container.getBoundingClientRect?.();
87
+ if (!afterLoad || !bounds || bounds.bottom <= bounds.top) return false;
88
+ this.container.scrollTop = this.container.scrollHeight;
89
+ this.pendingHistoryEntry = null;
90
+ return true;
91
+ }
92
+ leaveHistory(selectedMessageId) {
93
+ this.savedHistoryViewport = {
94
+ selectedMessageId,
95
+ anchor: this.visibleHistoryAnchor(),
96
+ scrollTop: this.container.scrollTop,
97
+ };
98
+ this.pendingHistoryEntry = null;
99
+ }
100
+ enterHistory(selectedMessageId) {
101
+ const saved = this.savedHistoryViewport?.selectedMessageId === selectedMessageId
102
+ ? this.savedHistoryViewport : null;
103
+ this.pendingHistoryEntry = {
104
+ selectedMessageId,
105
+ anchor: saved?.anchor ?? null,
106
+ scrollTop: saved?.scrollTop ?? this.container.scrollTop,
107
+ };
108
+ this.placeHistoryEntry(false);
109
+ }
48
110
  reset() {
49
111
  for (const row of this.activeRows.values()) row.remove();
50
112
  this.activeRows.clear();
51
113
  this.completed.clear();
52
114
  }
53
115
  load(messages) {
54
- const pinned = this.nearBottom();
116
+ const hidden = this.container.hidden;
117
+ const pinned = !hidden && this.nearBottom();
55
118
  const readingPosition = this.container.scrollTop;
56
- const anchor = pinned ? null : this.visibleHistoryAnchor();
119
+ const anchor = hidden || pinned ? null : this.visibleHistoryAnchor();
57
120
  const previous = this.historyRows;
58
121
  this.historyRows = new Map();
59
122
  const rows = messages.map(message => {
@@ -81,14 +144,10 @@ export class TranscriptView {
81
144
  return row;
82
145
  });
83
146
  this.container.replaceChildren(...rows, ...this.activeRows.values());
84
- if (pinned) this.container.scrollTop = this.container.scrollHeight;
85
- else if (anchor) {
86
- const row = this.historyRows.get(anchor.id) ?? rows[0];
87
- const rect = row?.getBoundingClientRect?.();
88
- const viewport = this.container.getBoundingClientRect?.();
89
- this.container.scrollTop = rect && viewport
90
- ? this.container.scrollTop + rect.top - viewport.top - anchor.offset
91
- : readingPosition;
92
- } else this.container.scrollTop = readingPosition;
147
+ if (hidden) this.container.scrollTop = readingPosition;
148
+ else if (this.placeHistoryEntry(true)) return;
149
+ else if (pinned) this.container.scrollTop = this.container.scrollHeight;
150
+ else if (anchor) this.restoreHistoryAnchor(anchor, readingPosition, true);
151
+ else this.container.scrollTop = readingPosition;
93
152
  }
94
153
  }