diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 6792dd2..ba81796 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -108,7 +108,7 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣
- 打开分拣台不等 B站:先用上次读到的收藏夹列表,打开上次那个收藏夹的快照(稍后再看也一样,随后照常重读它),同时在后台重新读收藏夹列表。读回来只更新收藏夹列表和数量;打开的收藏夹还在勾选里就不重开,页签、筛选、选中都留着。在收藏夹设置里勾选或取消勾选别的收藏夹也一样,「所有收藏夹」「已出分拣范围」在勾选变了时重开。
- 收藏夹设置在别处改了(设置页「导入设置」、AI 打标签弹窗里的「允许 AI 去掉已有标签」),开着的收藏夹马上用新值,和关注、摘读记录读自己的设置一样。收藏夹设置、关注设置、摘读记录设置的弹窗开着时别处改了设置,「保存」只写弹窗里改过的项,没动的项留别处写的值(`TriageUi.changedFields`);收藏夹设置里的收藏夹勾选也一样,没动勾选就不写。为什么:保存把弹窗里的旧值全写回去,会把刚导入的设置改回去。
- 打开弹窗、打开或切回侧边栏、进入专注模式都不重新读视频:页面已经读好的直接用(没有字幕的也不重读),正在读的等它读完,还没读的才读;只有点「刷新」才重新读。站内换视频时,专注模式开着就马上读新视频;没开时页面不自己读,等侧边栏(跟着标签页)、弹窗或播放器上的 ✦ 来要时才读一次,几个入口拿到的都是这一次,不会先闪回上一个视频。为什么:同一个视频反复读只会多碰 B站 的限流,也让几个入口看到的不一样。
-- 侧边栏在视频的标题栏里有「字幕」下拉框(标题下面一行,弹窗那一条的样式,32px 高;普通网页不显示),和弹窗、专注模式选的是同一条字幕:哪边换了,另外两边跟着变(视频页换完发 `mdg-subtitle-changed`)。下拉框的内容和弹窗一样(`MdgSites.fillSubtitleSelect`):只有一条照常能点,没有字幕时灰掉、框里写原因;换的时候写「正在切换字幕…」,失败写「字幕切换失败:原因」,侧边栏写在对话区的提示条里,对话已经开始时换成功会提示「已切换字幕:…」。AI 正在回答时灰掉(「回答完才能换字幕」):这条回答用的是提问时的字幕。视频页关了、从别处读出来的历史对话只显示它的字幕,灰掉写「在这个视频的页面上才能换字幕」。换了以后下一次提问就用新字幕,已保存的对话也一样。为什么:开着侧边栏切视频时,不用再打开弹窗去选字幕(2026-10-25 用户定位置和样式)。
+- 侧边栏在视频的标题栏里有「字幕」下拉框(标题下面一行,弹窗那一条的样式,32px 高;普通网页不显示),和弹窗、专注模式选的是同一条字幕:哪边换了,另外两边跟着变(视频页换完发 `mdg-subtitle-changed`)。下拉框的内容和弹窗一样(`MdgSites.fillSubtitleSelect`):只有一条照常能点,没有字幕时灰掉、框里写原因;换的时候写「正在切换字幕…」,失败写「字幕切换失败:原因」,侧边栏写在对话区的提示条里,对话已经开始时换成功会提示「已切换字幕:…」。AI 正在回答时灰掉(「回答完才能换字幕」):这条回答用的是提问时的字幕。视频页关了、从别处读出来的历史对话只显示它的字幕,灰掉写「在这个视频的页面上才能换字幕」。换了以后下一次提问就用新字幕,已保存的对话也一样。为什么:开着侧边栏切视频时,不用再打开弹窗去选字幕(2026-10-25 用户定位置和样式)。字幕下拉框后面有「刷新」,和弹窗右上角的一样:重新读视频和字幕,读的时候图标转圈、不能点,读失败变红,下次读成功再恢复;「重试」也走它,一样转圈。为什么:刷新就是重新读字幕,放在字幕旁边(2026-10-26 用户定)。
- 设置页「带上前 N 条热门评论」关着时,没有字幕的视频照样带热门评论(视频页、弹窗、侧边栏、「逐个视频笔记」都一样),设置页这一行下面写明。为什么:没有字幕时笔记只剩简介,评论是这时主要的内容。
- 收藏夹的「一篇摘录」里每个视频带的和这个视频自己的笔记一样(`MdgNote.triageExtras`):AI 总结连同「AI 判断:…」那一行、所有收藏夹的分拣标签加摘读记录的标签、星标、备注。「一篇摘录」和「逐个视频笔记」的索引写入 Obsidian 时,放在笔记目录模板里第一个按视频取值的变量(`{{author}}` `{{upload_date}}` `{{id}}` `{{video_id}}` `{{created}}`)所在那一层之前(`MdgNote.sharedNoteFolder`):`MoonDigest/{{site}}/{{author}}` 放 `MoonDigest/bilibili`,`{{upload_date}}/{{site}}` 放库的根目录。为什么:一篇里有很多视频,没有一个作者或日期能代表它;按整层截,不会出现 `bilibili-` 这种半截的目录名。
- 改了笔记相关的设置(附带热门评论、笔记属性、默认标签……)以后,开着的视频页马上按新设置重新生成笔记:弹窗的复制、下载、详情和侧边栏拿到的,和这时写入 Obsidian 的一样(`content.js` 的设置监听)。为什么:同一个弹窗里几个按钮给出不同的笔记,分不清哪个对。
diff --git a/extension/conventions.selftest.js b/extension/conventions.selftest.js
index 3af9383..f1c2254 100644
--- a/extension/conventions.selftest.js
+++ b/extension/conventions.selftest.js
@@ -53,6 +53,7 @@ const ALLOW = [
["options.js", "button.disabled = Boolean(active);", "保存和测试连接互等:在跑的那个写「保存中…」「测试中…」并 aria-busy"],
["options.js", "testBtn.disabled = true;", "AI 平台测试中:同一行写「正在测试…」并 aria-busy,完了 setReason 接手"],
["popup.js", "button.disabled = busy;", "弹窗按钮在跑:按钮文字换成在做什么,aria-busy"],
+ ["sidepanel.js", "els.refreshBtn.disabled = true;", "侧边栏刷新在读:图标转圈并 aria-busy,finally 打开"],
["triage/fav-base.js", "btn.disabled = Boolean(text);", "分拣台按钮在跑:按钮文字换成在做什么,aria-busy"],
["triage/follow.js", "btn.disabled = syncing(j);", "关注刷新在跑:aria-busy,进度胶囊写进度"],
["triage/follow.js", "btn.disabled = true;", "点了刷新(第 2 行、空状态或提示里的)、后台还没回:回来后 renderSync 接手(aria-busy)或失败时打开"],
diff --git a/extension/dev-sidepanel/index.html b/extension/dev-sidepanel/index.html
index 828a75a..a34173e 100644
--- a/extension/dev-sidepanel/index.html
+++ b/extension/dev-sidepanel/index.html
@@ -17,7 +17,17 @@
-
+
diff --git a/extension/sidepanel.css b/extension/sidepanel.css
index ffed5d9..907e3d2 100644
--- a/extension/sidepanel.css
+++ b/extension/sidepanel.css
@@ -26,15 +26,45 @@ body {
flex-shrink: 0;
}
-/* 字幕 under the video, the popup's bar (popup.css .subrow), 32px. */
-.sp-header:has(.sp-subrow:not([hidden])) {
+/* 字幕 under the video, the popup's bar (popup.css .subrow), 32px, with 刷新 after it. */
+.sp-header:has(.sp-subline:not([hidden])) {
flex-wrap: wrap;
row-gap: 8px;
}
-.sp-subrow {
+/* Same gap and width as the row above, so 刷新 sits under 设置 and the bar ends where the model select does. */
+.sp-subline {
flex-basis: 100%;
display: flex;
+ gap: 8px;
+ min-width: 0;
+}
+
+.sp-subline[hidden] {
+ display: none;
+}
+
+.sp-subline .sp-icon-btn {
+ height: 32px;
+ border: 1px solid transparent;
+}
+
+/* While reading, the refresh icon itself spins instead of the shared spinner; red only when the last read failed (popup.css .icon-btn). */
+.sp-subline .sp-icon-btn[aria-busy="true"]::before { content: none; }
+.sp-subline .sp-icon-btn[aria-busy="true"] svg { animation: busy-spin 0.8s linear infinite; }
+@media (prefers-reduced-motion: reduce) {
+ .sp-subline .sp-icon-btn[aria-busy="true"] svg { animation: none; }
+}
+
+.sp-subline .sp-icon-btn.is-error {
+ color: var(--danger);
+ background: var(--danger-bg);
+ border-color: var(--danger-border);
+}
+
+.sp-subrow {
+ flex: 1;
+ display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
@@ -45,10 +75,6 @@ body {
font-size: var(--fs-sm);
}
-.sp-subrow[hidden] {
- display: none;
-}
-
.sp-subrow .k {
color: var(--faint);
white-space: nowrap;
diff --git a/extension/sidepanel.html b/extension/sidepanel.html
index 1498f16..545e2af 100644
--- a/extension/sidepanel.html
+++ b/extension/sidepanel.html
@@ -17,7 +17,17 @@
-
+
diff --git a/extension/sidepanel.js b/extension/sidepanel.js
index de9eebd..ba03427 100644
--- a/extension/sidepanel.js
+++ b/extension/sidepanel.js
@@ -37,6 +37,7 @@ const els = {
modelSelect: document.getElementById("spModelSelect"),
subtitleRow: document.getElementById("spSubtitleRow"),
subtitleSelect: document.getElementById("spSubtitleSelect"),
+ refreshBtn: document.getElementById("spRefreshBtn"),
settingsBtn: document.getElementById("spSettingsBtn"),
newChatBtn: document.getElementById("spNewChatBtn"),
presetBtn: document.getElementById("spPresetBtn"),
@@ -180,6 +181,7 @@ function bindEvents() {
});
els.previousVideoBar?.addEventListener("click", handlePreviousVideoBarClick);
els.subtitleSelect.addEventListener("change", () => void pickSubtitle());
+ els.refreshBtn.addEventListener("click", () => void refreshContextManually());
chrome.runtime.onMessage.addListener((message, sender) => {
if (message?.type === "mdg-subtitle-changed") {
if (subtitleFrom(sender)) void rereadShownContext();
@@ -781,6 +783,7 @@ async function readContextState({ refresh = false, silent = false, follow = fals
}
return false;
}
+ els.refreshBtn.classList.remove("is-error");
if (effects.some((effect) => effect.type === "followed")) {
return FOLLOWED_LIVE_VIDEO;
}
@@ -1545,8 +1548,12 @@ async function refreshContextManually() {
return;
}
refreshingContext = true;
+ // 刷新 after 字幕 spins for every forced read (重试 too) and turns red when it failed (until a read succeeds), as the popup's does.
+ els.refreshBtn.disabled = true;
+ els.refreshBtn.setAttribute("aria-busy", "true");
try {
const ok = await loadContextState({ refresh: true });
+ if (!ok) els.refreshBtn.classList.add("is-error");
// A question sent while it read: redrawing the empty state would wipe it and its reply.
if (ok && !state.view().streaming) {
const { context, messages } = state.view();
@@ -1558,6 +1565,8 @@ async function refreshContextManually() {
}
} finally {
refreshingContext = false;
+ els.refreshBtn.disabled = false;
+ els.refreshBtn.setAttribute("aria-busy", "false");
}
}
diff --git a/extension/sidepanel.selftest.js b/extension/sidepanel.selftest.js
index a851177..b4cfe07 100644
--- a/extension/sidepanel.selftest.js
+++ b/extension/sidepanel.selftest.js
@@ -905,6 +905,18 @@ const tick = () => new Promise((r) => setImmediate(r));
assert.ok(/value="u2"[^>]*selected/.test(select.innerHTML));
// Picks made on that page (the popup, 专注模式) are followed; another frame's are not.
assert.deepStrictEqual([ctx.subtitleFrom({ tab: { id: 7 }, frameId: 0 }), ctx.subtitleFrom({ tab: { id: 7 }, frameId: 3 })], [true, false]);
+ // 刷新 after 字幕 reads again: busy while it reads, red after a failed read until one succeeds.
+ const realBtn = els.refreshBtn;
+ const classes = new Set();
+ const attrs = {};
+ els.refreshBtn = { disabled: false, setAttribute: (k, v) => (attrs[k] = v), classList: { add: (c) => classes.add(c), remove: (c) => classes.delete(c) } };
+ answer = async () => (assert.deepStrictEqual([els.refreshBtn.disabled, attrs["aria-busy"]], [true, "true"]), { ok: false, error: "B站限流,请稍后再试" });
+ await ctx.refreshContextManually();
+ assert.deepStrictEqual([els.refreshBtn.disabled, attrs["aria-busy"], classes.has("is-error")], [false, "false", true]);
+ answer = async () => ({ ok: true, payload: { ...video, subtitleOptions: tracks("u2"), subtitleMissing: "视频没有字幕" } });
+ await ctx.refreshContextManually();
+ assert.strictEqual(classes.has("is-error"), false);
+ els.refreshBtn = realBtn;
// A video read without its page (closed) only says its track.
show({ ...video, subtitleLang: "中文" });
ctx.syncSendUi();