agent-loft 0.2.32 → 0.2.34

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.
@@ -1,19 +1,20 @@
1
1
  import { agentIcon, fill, h, icon, starsLoader } from "../lib/dom.js";
2
2
  import { api } from "../lib/api.js";
3
3
  import { clockTime, liveDuration, serverNow, setServerTime, tidyFooter } from "../lib/time.js";
4
- import { copyText, renderMarkdown } from "../lib/markdown.js";
4
+ import { copyText, patchMarkdown, renderMarkdown } from "../lib/markdown.js";
5
5
  import { S, ui } from "../state.js";
6
6
  import { WS } from "../lib/ws.js";
7
7
  import { agentLabel, convTitle, renderSidebar } from "./sidebar.js";
8
8
  import { baseName, renderContext, renderTopbar, updateCtxVisibility, usableAgents } from "./topbar.js";
9
- import { renderInteraction, upsertInteraction } from "./interaction.js";
9
+ import { insertCard, renderInteraction, upsertInteraction } from "./interaction.js";
10
10
  import { renderChips, setBusy } from "./composer.js";
11
11
  import { renderScheduled } from "./schedule.js";
12
- import { changeCwd, forkConversation } from "./conv-actions.js";
12
+ import { changeCwd, doAction, forkConversation } from "./conv-actions.js";
13
+ import { scheduleNav } from "./msg-nav.js";
13
14
 
14
15
  // ---------- 打开会话 ----------
15
16
 
16
- /** 从定时任务页、技能页回到对话:把输入框放回来 */
17
+ /** 从定时任务页回到对话:把输入框放回来 */
17
18
  function leaveTasksView() {
18
19
  if (!S.view) return;
19
20
  S.view = null;
@@ -39,6 +40,7 @@ export function newDraft() {
39
40
  renderEmpty();
40
41
  renderChips();
41
42
  renderSidebar();
43
+ scheduleNav();
42
44
  ui.input?.focus();
43
45
  }
44
46
 
@@ -101,18 +103,35 @@ export async function openConversation(key) {
101
103
  }
102
104
  }
103
105
  if (token !== S.loadToken) return; // 期间又切到别的会话了
106
+ // 侧栏分页:打开的可能是还没拉到的那条(通知、地址里带的),拿到了就补进侧栏
107
+ const known = S.convs.has(key);
104
108
  S.convs.set(key, data.info);
109
+ if (!known) renderSidebar();
105
110
  S.allowedAgents = data.allowedAgents;
106
111
  if (Number.isFinite(data.serverTime)) setServerTime(data.serverTime);
107
112
  fill(ui.msgInner, );
108
- for (const m of data.messages) {
109
- if (m.role === "user") addItem({ type: "user", text: m.text, files: m.attachments ?? [], origin: null });
110
- else addItem({ type: "ai", steps: m.steps ?? [], committed: m.text, partial: "", done: true, footer: m.footer, at: m.ts, anchor: m.anchor });
113
+ // 历史一次性加进来,最后再统一排版,免得每加一条就强制排版一次
114
+ bulk = true;
115
+ try {
116
+ for (const m of data.messages) {
117
+ if (m.role === "user") addItem({ type: "user", text: m.text, files: m.attachments ?? [], origin: null });
118
+ else if (m.role === "question") addItem({ type: "ix", id: m.question.id, ix: historyQuestion(key, m) });
119
+ else addItem({ type: "ai", steps: m.steps ?? [], committed: m.text, partial: "", done: true, footer: m.footer, at: m.ts, anchor: m.anchor, segment: m.segment });
120
+ }
121
+ S.seq.set(key, 0);
122
+ // 进行中这一轮弹过的卡(含已处理的):重放到 ix 事件时放回原位
123
+ replayCards = new Map((data.interactions ?? []).map((ix) => [ix.id, ix]));
124
+ for (const env of data.live) applyEvent(env);
125
+ replayCards = new Map();
126
+ S.seq.set(key, Math.max(S.seq.get(key) ?? 0, data.seq));
127
+ for (const ix of data.pending) upsertInteraction(ix);
128
+ } finally {
129
+ bulk = false;
111
130
  }
112
- S.seq.set(key, 0);
113
- for (const env of data.live) applyEvent(env);
114
- S.seq.set(key, Math.max(S.seq.get(key) ?? 0, data.seq));
115
- for (const ix of data.pending) upsertInteraction(ix);
131
+ clampLongUsers(S.items);
132
+ markLatestAi();
133
+ updateCtxVisibility();
134
+ scheduleNav();
116
135
  if (S.items.length === 0) {
117
136
  ui.msgInner.append(
118
137
  h(
@@ -130,6 +149,22 @@ export async function openConversation(key) {
130
149
  scrollToBottom();
131
150
  }
132
151
 
152
+ /** 打开会话、重放进行中这一轮时,ix 事件对应的卡片内容(见 openConversation) */
153
+ let replayCards = new Map();
154
+
155
+ /** 会话记录里还原出来的提问(问了什么、你怎么答的)→ 画成一张已处理的提问卡 */
156
+ function historyQuestion(key, m) {
157
+ const q = m.question;
158
+ return {
159
+ id: q.id,
160
+ convKey: key,
161
+ spec: { kind: "question", title: q.title, questions: q.questions },
162
+ selections: q.selections,
163
+ freeText: q.freeText,
164
+ settled: { settlement: q.skipped ? "skipped" : "answered", by: "", at: m.ts ?? 0 },
165
+ };
166
+ }
167
+
133
168
  // ---------- 消息渲染 ----------
134
169
 
135
170
  // 滚动规则(和 ChatGPT 一样):
@@ -183,8 +218,8 @@ function pinToTop(el) {
183
218
  updateToBottomBtn();
184
219
  }
185
220
 
186
- /** 用户自己动了滚动:停止自动滚动 */
187
- function userScrolled() {
221
+ /** 用户自己动了滚动(或者点了快速定位、展开了长消息):停止自动滚动 */
222
+ export function userScrolled() {
188
223
  ui.follow = false;
189
224
  ui.smoothUntil = 0;
190
225
  }
@@ -222,11 +257,19 @@ function markLatestAi() {
222
257
  for (const it of S.items) if (it.type === "ai") it.el?.classList.toggle("latest", it === latest);
223
258
  }
224
259
 
260
+ /** 正在批量加历史:addItem 先不排版,加完由 openConversation 统一做 */
261
+ let bulk = false;
262
+
225
263
  export function addItem(item) {
226
264
  ui.msgInner.querySelector(".empty")?.remove();
227
265
  item.el = renderItem(item);
228
266
  ui.msgInner.append(item.el);
229
267
  S.items.push(item);
268
+ if (bulk) return item;
269
+ if (item.type === "user") {
270
+ clampLongUsers([item]);
271
+ scheduleNav();
272
+ }
230
273
  markLatestAi();
231
274
  updateCtxVisibility();
232
275
  afterLayout();
@@ -243,6 +286,11 @@ export function updateItem(item) {
243
286
  flushScheduled = false;
244
287
  for (const it of dirty) {
245
288
  if (!it.el?.isConnected) continue;
289
+ // 回复原地更新;其它(交互卡片等)不常变,整个重建
290
+ if (it.type === "ai") {
291
+ patchAi(it);
292
+ continue;
293
+ }
246
294
  const next = renderItem(it);
247
295
  it.el.replaceWith(next);
248
296
  it.el = next;
@@ -330,6 +378,59 @@ export function openLightbox(src, name, download) {
330
378
  document.addEventListener("keydown", onKey, true);
331
379
  }
332
380
 
381
+ /** 用户消息超过这个高度(约 10 行)就先收起来,像 ChatGPT;只多出一两行的不收 */
382
+ const USER_CLAMP = 260;
383
+
384
+ /** 太长的用户消息收起来,下面放「展开显示」。先把高度都量完再统一改,只排版一次 */
385
+ function clampLongUsers(items) {
386
+ const long = items.filter((it) => {
387
+ if (it.type !== "user" || !it.bubble || it.clampChecked) return false;
388
+ it.clampChecked = true;
389
+ return it.bubble.scrollHeight > USER_CLAMP + 60;
390
+ });
391
+ for (const it of long) {
392
+ const bubble = it.bubble;
393
+ bubble.classList.add("clamped");
394
+ const btn = h(
395
+ "button",
396
+ {
397
+ class: "msg-more",
398
+ onclick: () => {
399
+ // 自己展开 / 收起:别被自动滚动拉回底部
400
+ userScrolled();
401
+ const open = !bubble.classList.toggle("clamped");
402
+ btn.textContent = open ? "收起" : "展开显示";
403
+ // 收起后按钮可能跑到视口上面去了:拉回来
404
+ if (!open) btn.scrollIntoView({ block: "nearest" });
405
+ },
406
+ },
407
+ "展开显示",
408
+ );
409
+ bubble.after(btn);
410
+ }
411
+ }
412
+
413
+ /** 排队中的消息下面:「排队中 · 打断并发送」;停止、会话切换后没发出去的:「没有发出」 */
414
+ function queueNote(item, state) {
415
+ if (state === "dropped") return h("div", { class: "queue-note dropped" }, "没有发出");
416
+ return h(
417
+ "div",
418
+ { class: "queue-note" },
419
+ h("span", { class: "shimmer" }, "排队中,等上一条回复完"),
420
+ h("button", { class: "queue-jump", title: "停掉正在进行的回复,马上处理这条", onclick: () => void doAction("jump", item.id) }, "打断并发送"),
421
+ );
422
+ }
423
+
424
+ /** state:queued / dropped / null(轮到它了,去掉标记) */
425
+ function setQueueState(item, state) {
426
+ item.queued = state === "queued";
427
+ const next = state ? queueNote(item, state) : null;
428
+ if (item.queueEl && next) item.queueEl.replaceWith(next);
429
+ else if (item.queueEl) item.queueEl.remove();
430
+ else if (next) item.el.insertBefore(next, item.el.querySelector(".user-actions"));
431
+ item.queueEl = next;
432
+ }
433
+
333
434
  function renderItem(item) {
334
435
  switch (item.type) {
335
436
  case "user": {
@@ -340,7 +441,8 @@ function renderItem(item) {
340
441
  // 只发了附件、没有文字:「来自飞书」挂在附件上面
341
442
  !item.text && origin ? h("span", { class: "origin" }, origin) : null,
342
443
  renderUserFiles(item.files),
343
- item.text ? h("div", { class: "msg-user" }, origin ? h("span", { class: "origin" }, origin) : null, item.text) : null,
444
+ item.text ? (item.bubble = h("div", { class: "msg-user" }, origin ? h("span", { class: "origin" }, origin) : null, item.text)) : null,
445
+ (item.queueEl = item.queued ? queueNote(item, "queued") : null),
344
446
  item.text
345
447
  ? h("div", { class: "user-actions" }, h("button", { class: "icon-btn", title: "复制", onclick: () => copyText(item.text) }, icon("copy")))
346
448
  : null,
@@ -361,74 +463,113 @@ function renderItem(item) {
361
463
  /** 定时任务的 agent 判断这次不用提醒时,回复第一行写的暗号(见 core/task-prompt.ts) */
362
464
  const SILENT_MARK = "[[静默]]";
363
465
 
466
+ /**
467
+ * 回复流式输出时每帧都会更新:不整条重建,只改变了的部分(item.v 记着各部分的元素)。
468
+ * 前面已经输出完的 markdown 块、步骤列表、星星动画都原地不动,选中的文字和滚动位置也就不会丢。
469
+ */
364
470
  function renderAi(item) {
471
+ item.v = {};
472
+ item.el = h("div", { class: "msg-ai" });
473
+ patchAi(item);
474
+ return item.el;
475
+ }
476
+
477
+ function patchAi(item) {
478
+ const v = item.v;
365
479
  const raw = item.committed + (item.partial ? (item.committed ? "\n\n" : "") + item.partial : "");
366
480
  const silent = raw.trimStart().startsWith(SILENT_MARK);
367
481
  const body = silent ? raw.trimStart().slice(SILENT_MARK.length).trim() : raw;
368
482
  const working = !item.done;
369
- const steps =
370
- item.steps.length > 0
371
- ? h(
372
- "details",
373
- {
374
- class: "steps",
375
- open: item.stepsOpen ?? false,
376
- ontoggle: (e) => (item.stepsOpen = e.currentTarget.open),
377
- },
378
- h(
379
- "summary",
380
- {},
381
- h(
382
- "span",
383
- { class: `steps-text${working ? " shimmer" : ""}` },
384
- working
385
- ? item.steps[item.steps.length - 1].split("\n")[0]
386
- : `${item.segment ? "" : "已完成 "}${item.steps.length} 步${item.segment ? "过程" : ""}`,
387
- ),
388
- h("span", { class: "chev" }, "›"),
389
- ),
390
- h("ol", {}, item.steps.map((s) => h("li", {}, s))),
391
- )
392
- : null;
393
- return h(
394
- "div",
395
- { class: "msg-ai" },
396
- steps,
397
- // 定时任务这次没什么要说的:折叠成一行,展开能看到它顺手记的检查结果
398
- silent && body
399
- ? h("details", { class: "silent-run" }, h("summary", {}, "🔕 定时任务:这次无需提醒"), renderMarkdown(body))
400
- : silent
401
- ? h("div", { class: "silent-run" }, "🔕 定时任务:这次无需提醒")
402
- : body
403
- ? renderMarkdown(body)
483
+
484
+ if (item.steps.length > 0) {
485
+ if (!v.steps) {
486
+ v.stepsText = h("span", { class: "steps-text" });
487
+ v.stepsList = h("ol", {});
488
+ v.steps = h(
489
+ "details",
490
+ { class: "steps", open: item.stepsOpen ?? false, ontoggle: (e) => (item.stepsOpen = e.currentTarget.open) },
491
+ h("summary", {}, v.stepsText, h("span", { class: "chev" }, "›")),
492
+ v.stepsList,
493
+ );
494
+ }
495
+ setText(
496
+ v.stepsText,
497
+ working
498
+ ? item.steps[item.steps.length - 1].split("\n")[0]
499
+ : `${item.segment ? "" : "已完成 "}${item.steps.length} 步${item.segment ? "过程" : ""}`,
500
+ );
501
+ v.stepsText.classList.toggle("shimmer", working);
502
+ // 步骤只会往后加,补上新的就行
503
+ const ol = v.stepsList;
504
+ if (ol.childElementCount > item.steps.length) ol.replaceChildren();
505
+ for (let i = ol.childElementCount; i < item.steps.length; i++) ol.append(h("li", {}, item.steps[i]));
506
+ }
507
+
508
+ // 定时任务这次没什么要说的:折叠成一行,展开能看到它顺手记的检查结果
509
+ const bodyKey = silent ? `silent\n${body}` : body ? "md" : "";
510
+ if (v.bodyKey !== bodyKey) {
511
+ v.bodyKey = bodyKey;
512
+ v.body = silent
513
+ ? body
514
+ ? h("details", { class: "silent-run" }, h("summary", {}, "🔕 定时任务:这次无需提醒"), renderMarkdown(body))
515
+ : h("div", { class: "silent-run" }, "🔕 定时任务:这次无需提醒")
516
+ : body
517
+ ? h("div", { class: "md" })
518
+ : null;
519
+ }
520
+ if (bodyKey === "md") patchMarkdown(v.body, body, { live: working });
521
+
522
+ // 还在回复:末尾挂上星星动画和实时用时;什么都还没出来时旁边带一句「思考中…」
523
+ if (working) {
524
+ if (!v.thinking) {
525
+ v.hint = h("span", { class: "shimmer" }, "思考中…");
526
+ v.thinking = h(
527
+ "div",
528
+ { class: "thinking" },
529
+ starsLoader(),
530
+ item.startAt ? h("span", { class: "ai-elapsed", "data-start": item.startAt }, liveDuration(serverNow() - item.startAt)) : null,
531
+ );
532
+ }
533
+ const hint = !body && item.steps.length === 0;
534
+ if (hint && !v.hint.isConnected) v.thinking.firstChild.after(v.hint);
535
+ else if (!hint) v.hint.remove();
536
+ } else v.thinking = null;
537
+
538
+ if (!item.error) v.error = null;
539
+ else if (v.error?.textContent !== item.error) v.error = h("div", { class: "error-box" }, item.error);
540
+
541
+ placeChildren(item.el, [v.steps, v.body, v.thinking, v.error, renderAiActions(item, body)]);
542
+ }
543
+
544
+ function setText(el, text) {
545
+ if (el.textContent !== text) el.textContent = text;
546
+ }
547
+
548
+ /** 按顺序放好子元素:不要的删掉,缺的插进去,已经在的不挪(挪动会让动画重播、滚动位置复位) */
549
+ function placeChildren(parent, nodes) {
550
+ const want = new Set(nodes.filter(Boolean));
551
+ for (const c of [...parent.childNodes]) if (!want.has(c)) c.remove();
552
+ let ref = parent.firstChild;
553
+ for (const n of want) {
554
+ if (n === ref) ref = ref.nextSibling;
555
+ else parent.insertBefore(n, ref);
556
+ }
557
+ }
558
+
559
+ function renderAiActions(item, body) {
560
+ return item.done && !item.segment && (body || item.footer)
561
+ ? h(
562
+ "div",
563
+ { class: "ai-actions" },
564
+ body ? h("button", { class: "icon-btn", title: "复制", onclick: () => copyText(body) }, icon("copy")) : null,
565
+ item.anchor && S.convs.get(S.current)?.canFork
566
+ ? h("button", { class: "icon-btn", title: "从这里分叉成新对话", onclick: () => forkConversation(item.anchor) }, icon("fork"))
404
567
  : null,
405
- // 还在回复:末尾挂上星星动画和实时用时;什么都还没出来时旁边带一句「思考中…」
406
- working
407
- ? h(
408
- "div",
409
- { class: "thinking" },
410
- starsLoader(),
411
- !body && item.steps.length === 0 ? h("span", { class: "shimmer" }, "思考中…") : null,
412
- item.startAt
413
- ? h("span", { class: "ai-elapsed", "data-start": item.startAt }, liveDuration(serverNow() - item.startAt))
414
- : null,
415
- )
416
- : null,
417
- item.error ? h("div", { class: "error-box" }, item.error) : null,
418
- item.done && !item.segment && (body || item.footer)
419
- ? h(
420
- "div",
421
- { class: "ai-actions" },
422
- body ? h("button", { class: "icon-btn", title: "复制", onclick: () => copyText(body) }, icon("copy")) : null,
423
- item.anchor && S.convs.get(S.current)?.canFork
424
- ? h("button", { class: "icon-btn", title: "从这里分叉成新对话", onclick: () => forkConversation(item.anchor) }, icon("fork"))
425
- : null,
426
- item.footer || item.at
427
- ? h("span", { class: "footer-note" }, [item.at ? clockTime(item.at) : "", tidyFooter(item.footer)].filter(Boolean).join(" "))
428
- : null,
429
- )
430
- : null,
431
- );
568
+ item.footer || item.at
569
+ ? h("span", { class: "footer-note" }, [item.at ? clockTime(item.at) : "", tidyFooter(item.footer)].filter(Boolean).join(" "))
570
+ : null,
571
+ )
572
+ : null;
432
573
  }
433
574
 
434
575
  /**
@@ -454,21 +595,38 @@ export function applyEvent(env) {
454
595
  const ev = env.ev;
455
596
  switch (ev.type) {
456
597
  case "user": {
457
- if (S.turnAi && !S.turnAi.done && S.turnAi.steps.length === 0 && !S.turnAi.committed) {
598
+ // 排队的消息:正在进行的那一轮照常在上面接着输出,这条等轮到它(queue 事件)再挂占位
599
+ const queued = !!ev.queued;
600
+ if (!queued && S.turnAi && !S.turnAi.done && S.turnAi.steps.length === 0 && !S.turnAi.committed) {
458
601
  // 上一个占位还空着(比如消息被当成了作答),不再多留一个
459
602
  S.turnAi.el.remove();
460
603
  S.items = S.items.filter((i) => i !== S.turnAi);
461
604
  S.turnAi = null;
462
605
  }
463
- // 上一轮还没结束时发的消息会排队,不改正在进行那一轮的开始时间
464
- if (!S.turnAi || S.turnAi.done) turnStartAt = env.ts;
465
- const it = addItem({ type: "user", text: ev.text, files: ev.files ?? ev.attachments, origin: ev.origin });
606
+ if (!queued && (!S.turnAi || S.turnAi.done)) turnStartAt = env.ts;
607
+ const it = addItem({ type: "user", text: ev.text, files: ev.files ?? ev.attachments, origin: ev.origin, id: ev.id, queued });
466
608
  // 这个页面刚发的:顶到最上面
467
609
  if (S.pinNext && ev.origin?.channel === "web") {
468
610
  S.pinNext = false;
469
611
  pinToTop(it.el);
470
612
  }
471
- currentAi(env.ts);
613
+ if (!queued) currentAi(env.ts);
614
+ return;
615
+ }
616
+ case "ix": {
617
+ // 这里弹过一张卡:实时的由 WS 的 ix 消息放(upsertInteraction),这里只管重放时把它放回原位
618
+ const ix = S.pending.get(ev.id) ?? replayCards.get(ev.id);
619
+ if (ix) insertCard(ix);
620
+ return;
621
+ }
622
+ case "queue": {
623
+ const it = S.items.find((i) => i.type === "user" && i.id === ev.id);
624
+ if (it) setQueueState(it, ev.state === "dropped" ? "dropped" : null);
625
+ // 轮到它了(上一轮刚出结果):挂上「思考中」占位,用时从现在算。被当成提问的回答时那一轮还在跑,不用挂
626
+ if (ev.state === "started" && (!S.turnAi || S.turnAi.done)) {
627
+ turnStartAt = env.ts;
628
+ currentAi(env.ts);
629
+ }
472
630
  return;
473
631
  }
474
632
  case "step": {
@@ -0,0 +1,120 @@
1
+ import { h } from "../lib/dom.js";
2
+ import { S, ui } from "../state.js";
3
+ import { userScrolled } from "./messages.js";
4
+
5
+ // ---------- 消息区右边的快速定位(像网页版 ChatGPT) ----------
6
+ // 每条用户消息一道短横线,竖着排在滚动条旁边,正看到的那条高亮;平时淡淡的,滚动时清楚些。
7
+ // 鼠标移上去展开成列表,显示每条消息的开头,点一下跳过去。用户消息少于 3 条时不显示;窄屏(手机)不显示。
8
+
9
+ const MIN_USERS = 3;
10
+ let entries = [];
11
+ let activeIdx = -1;
12
+ let scheduled = false;
13
+ let scrollRaf = 0;
14
+ let scrollingTimer = 0;
15
+
16
+ export function createNav() {
17
+ ui.navTicks = h("div", { class: "msg-nav-ticks" });
18
+ ui.navPanel = h("div", { class: "msg-nav-panel" });
19
+ ui.nav = h("nav", { class: "msg-nav", hidden: true, "aria-label": "消息快速定位", onpointerenter: revealActiveRow }, ui.navTicks, ui.navPanel);
20
+ ui.messages.addEventListener("scroll", onScroll, { passive: true });
21
+ return ui.nav;
22
+ }
23
+
24
+ /** 用户消息变了(发了一条、打开别的会话):下一帧重建 */
25
+ export function scheduleNav() {
26
+ if (scheduled) return;
27
+ scheduled = true;
28
+ requestAnimationFrame(() => {
29
+ scheduled = false;
30
+ build();
31
+ });
32
+ }
33
+
34
+ function snippet(item) {
35
+ const text = (item.text ?? "").replace(/\s+/g, " ").trim();
36
+ if (text) return text.length > 60 ? `${text.slice(0, 60)}…` : text;
37
+ const names = (item.files ?? []).map((f) => (typeof f === "string" ? f : f.name));
38
+ return names.length ? `[附件] ${names.join("、")}` : "(空消息)";
39
+ }
40
+
41
+ function build() {
42
+ if (!ui.nav) return;
43
+ const users = S.view ? [] : S.items.filter((it) => it.type === "user" && it.el?.isConnected);
44
+ ui.nav.hidden = users.length < MIN_USERS;
45
+ activeIdx = -1;
46
+ if (ui.nav.hidden) {
47
+ entries = [];
48
+ ui.navTicks.replaceChildren();
49
+ ui.navPanel.replaceChildren();
50
+ return;
51
+ }
52
+ entries = users.map((item, i) => {
53
+ const label = snippet(item);
54
+ return {
55
+ item,
56
+ tick: h("button", { class: "msg-nav-tick", "aria-label": `第 ${i + 1} 条:${label}`, onclick: () => jump(item) }, h("i")),
57
+ row: h("button", { class: "msg-nav-row", onclick: () => jump(item) }, label),
58
+ };
59
+ });
60
+ ui.navTicks.replaceChildren(...entries.map((e) => e.tick));
61
+ ui.navPanel.replaceChildren(...entries.map((e) => e.row));
62
+ updateActive();
63
+ }
64
+
65
+ /** 顶栏叠在消息区上面:跳过去时让出它的高度 */
66
+ function covered() {
67
+ return Math.max(0, ui.topbar.getBoundingClientRect().bottom - ui.messages.getBoundingClientRect().top);
68
+ }
69
+
70
+ function jump(item) {
71
+ if (!item.el?.isConnected) return;
72
+ userScrolled();
73
+ ui.messages.scrollTo({ top: Math.max(0, item.el.offsetTop - covered() - 12), behavior: "smooth" });
74
+ }
75
+
76
+ function onScroll() {
77
+ if (!entries.length) return;
78
+ ui.nav.classList.add("scrolling");
79
+ clearTimeout(scrollingTimer);
80
+ scrollingTimer = setTimeout(() => ui.nav.classList.remove("scrolling"), 1200);
81
+ if (scrollRaf) return;
82
+ scrollRaf = requestAnimationFrame(() => {
83
+ scrollRaf = 0;
84
+ updateActive();
85
+ });
86
+ }
87
+
88
+ /** 正看到的:顶部往下三分之一处以上、最后一条用户消息;已经滚到底就是最后一条 */
89
+ function updateActive() {
90
+ const m = ui.messages;
91
+ const line = m.scrollTop + covered() + m.clientHeight * 0.3;
92
+ let idx = 0;
93
+ if (m.scrollHeight - m.scrollTop - m.clientHeight < 4) idx = entries.length - 1;
94
+ else {
95
+ for (let i = 0; i < entries.length; i++) {
96
+ if (entries[i].item.el.offsetTop <= line) idx = i;
97
+ else break;
98
+ }
99
+ }
100
+ if (idx === activeIdx) return;
101
+ entries[activeIdx]?.tick.classList.remove("on");
102
+ entries[activeIdx]?.row.classList.remove("on");
103
+ activeIdx = idx;
104
+ entries[idx].tick.classList.add("on");
105
+ entries[idx].row.classList.add("on");
106
+ keepVisible(ui.navTicks, entries[idx].tick);
107
+ }
108
+
109
+ /** 横线太多、竖条装不下时它自己能滚:让高亮的那道留在看得见的地方(不用 scrollIntoView,免得把外层也带着滚) */
110
+ function keepVisible(box, el) {
111
+ const top = el.offsetTop - box.offsetTop;
112
+ if (top < box.scrollTop) box.scrollTop = top;
113
+ else if (top + el.offsetHeight > box.scrollTop + box.clientHeight) box.scrollTop = top + el.offsetHeight - box.clientHeight;
114
+ }
115
+
116
+ /** 展开列表时:列表滚到正看到的那条 */
117
+ function revealActiveRow() {
118
+ const row = entries[activeIdx]?.row;
119
+ if (row) ui.navPanel.scrollTop = Math.max(0, row.offsetTop - ui.navPanel.clientHeight / 2);
120
+ }
@@ -101,6 +101,7 @@ function quotaLevel(p) {
101
101
  export function renderQuota() {
102
102
  if (!ui.quota) return;
103
103
  const list = S.quotaEnabled ? (quotaState.data ?? []) : [];
104
+ if (ui.sideBottom) ui.sideBottom.hidden = list.length === 0;
104
105
  if (list.length === 0) return fill(ui.quota);
105
106
  const rows = list.map((q) =>
106
107
  h(