主题
模块状态保持 + 「首次遇见于」进回顾
物理仓库:
/Users/larry/reading-browser(RB 桌面端) 创建:2026-08-11 · 状态:✅ 已完成(2026-08-12,M1-M7 全部落地,见文末「as-built」) 缘起:用户补充需求(2026-08-11)——「其他模块比如 review、词汇与笔记也应该保存状态」 姊妹计划:data-hygiene-identity-plan.md·popup-and-entry-points-plan.md
0. TL;DR
用户从「生词本『首次遇见于』想跳进回顾模块,且要能返回」这个具体需求,推广出一条 通用原则:模块切换应保持状态。核查确认这个直觉对,且Review 那侧的现状比 "没保存状态"更糟——它是主动销毁。
| 模块 | 现状 | 判定 |
|---|---|---|
| Read | tabs 在 useTabsStore、webview 不销毁 | ✅ 已保持 |
| Library | useLibraryStore 存了 tab/search/filter;但 page / selectedDetailId / entries 是组件 local useState | 🟡 保持了一半 |
| Review | 切走即 endSession(),主动清空 currentCard / positions / isRevealed / revisitNoteId | 🔴 丢用户进度 |
建议确立的统一原则:模块切换只换视图,不销毁状态;销毁只由用户显式动作触发。 定下这条,以后新模块不必逐个讨论。
1. Review:endSession 与「切模块」解绑(本计划优先级最高)
1.1 现状是主动销毁,不是"忘了保存"
ActivityBar.tsx:152-157:
js
const fromReview = useWorkspaceStore.getState().activeModule === 'review';
if (m !== 'review' && fromReview) {
useReviewStore.getState().endSession(); // ← 离开 review = 立刻销毁会话
}endSession()(useReviewStore.ts:148-178)会清空 currentCard / positions / activePositionIdx / isRevealed / sessionFilter / reviewedCount / activityMode / revisitNoteId。
体感:复习到第 7 张卡 → 想去查点东西 → 回来从头开始。
1.2 这个矛盾已经被打过三次补丁,根因没动
| 补丁 | 位置 | 注释原话 |
|---|---|---|
| Settings/Account 改弹 Modal 逃逸(Item C) | ActivityBar.tsx:148 | 「switchModule→endSession 会丢当前会话快照/进度」 |
| 复习卡「打开原文」只切 module 不动会话 | AddressBarReviewSegment.tsx:140-141 | 「只切 module 不动 isInFocusSession:复习会话原样留着……不会因为看了眼原文就把进度清掉」 |
endSession 里 activeTabId 回落 '' 的守卫 | useReviewStore.ts:159-166 | 2026-08-06 实测「点阅读卡住」 |
三处各自绕过,说明问题早被识别,只是当时选择了绕行。 本计划正面解决。
1.3 改法
ActivityBar.switchModule不再调endSession()(ActivityBar.tsx:154)—— 切走只是换视图endSession()的合法调用方收敛为两类(审核修正:初稿写成"只由用户显式结束触发", 漏了登出这一支):- 用户显式结束:复习完成页 / 显式「结束复习」按钮 —— 需新增(现在没有这个按钮, 切模块就是唯一的结束方式,这正是 §1.1 问题的来源)
- 🔒 登出:
useAuthStore.ts:139-141必须保留。红线 I3——不结束会话则isInFocusSession残留会让下一用户登录瞬间看到FocusReviewHeader, 且其 snapshot 指向已被resetAllTabs()销毁的 tab。这一支不许动。
enterReviewModule()现有的早退if (get().isInFocusSession) return;正好成为 "回来接着复习"的天然实现——它本来就是为此写的,只是此前永远走不到 (因为切走时已经endSession把isInFocusSession置 false 了)- Item C 的 Modal 逃逸可以退役(
ActivityBar.tsx:148-151):Settings/Account 恢复成正常模块切换。⚠️ 但退役与否单独判断——Modal 形态本身可能有其它体验理由 (复习中不希望整屏切走)。建议本轮先保留,只解绑endSession,两件事不耦合。
1.4 需要一并想清楚的两个副作用
preFocusSnapshot的语义变了:现在它在enterReviewModule拍、endSession还原。 解绑后,"进 review 时的 tab/panel 现场"会跨越多次模块往返存活。需确认endSession还原时快照里的 tab 可能早已不存在——现有守卫已处理 (useReviewStore.ts:166的snapshotExists回落''),沿用即可。- due 角标 / 数据新鲜度:会话保活意味着
currentCard可能是 10 分钟前取的。 若期间在别处改了该词(如在 Library 里标了熟词),卡片会是陈旧的。 建议:回到 review 模块时对currentCard做一次存在性校验(词还在生词本且未软删), 失效则静默取下一张。不做全量重取(那等于变相 endSession)。
2. Library:把「我正在看哪个词」提进 store
useLibraryStore 已存 activeTab / lastVocabTab / 各 tab 的 search 与 filter (注释明写「便于 tab 切回时保留筛选状态」)。缺的是 VocabPanel.tsx 里这些 local state:
| 字段 | 建议 | 理由 |
|---|---|---|
selectedDetailId | 提进 store | 「我正在看这个词」是明确的位置感,丢了最难受 |
page(翻到第几页) | 留 local | 重拉是正确的——数据可能已变 |
entries / isLoading | 留 local | 同上,缓存陈旧数据得不偿失 |
selectedIds(多选) | 留 local | 多选是瞬态操作意图,跨模块保留反而危险 |
四个 tab(notebook / known / browse + notes)各自一个 selectedDetailId 槽位, 不要共用一个(不同 tab 的 id 空间不同)。
3. 「首次遇见于」跳转到回顾模块
3.1 现状
WordMemorySection.tsx:23 的 openSource() = setActiveModule('read') + openContent({kind:'url', url}) —— 开一个普通浏览 tab,只看内容、不进复习语境。
用户要的是进 Review 模块的 revisit 活动。管道已存在: SentenceRevisitPanel.openPage()(SentenceRevisitPanel.tsx:90-98)做的正是 openRightPanel('annotations') + navigateRevisitTab(...)。
3.2 顺带修一个既有 bug:粘贴文档在「回顾」里显示"无来源"
SentenceRevisitPanel.tsx:93 只认 cached_file_path,没有就 showReviewEmptyState('nosource')。
但粘贴文本的正文本身就是 rb-cache 文件,不需要另存快照——它的 source_ref 形如 rb-cache://localhost/text/<id>.html,内容好端端在磁盘上。 实测:21 条 text 页里 10 条 cached_file_path 为空,它们在「回顾」里全都打不开。
修法:判据从"有没有 cached_file_path"改成**"有没有可读的本地 URL"**—— cached_file_path 优先,回落 source_ref(当它以 rb-cache:// 开头时)。 抽成一个共享 helper(WordMemorySection 与 SentenceRevisitPanel 都要用)。
📌 2026-08-11 数据卫生落地后的前置事实(做本条前必读): 新增了
notes::canonical_source_url(conn, url)(commands/notes.rs)—— 把rb-cache://localhost/web/<hash>.html按reading_pages.cached_file_path反查回真实source_ref,已在 5 个写入命令入口生效。 影响本条两点: ① 在快照/回顾页上查词、划线,来源会正确归到原文那一行,不再分裂 → M5「首次遇见于进 revisit」不必再担心把学习痕迹写到 rb-cache 假来源上; ②content-script/core/ipc.js::effectiveSourceUrl现在对快照页返回 href (此前返回空串)。若 M4/M5 要给 revisit 页注入__RB_REVIEW_MODE, 注意它优先级更高(ipc.js:15-16),会盖过这条新回落——两者不冲突, 但别以为改了 ipc.js 就万事大吉。⚠️ 另:实测确认 EPUB 章节的
cached_file_path也是web/<hash>.html(章节快照),不是epub/…。本条要写的 helper 若按路径前缀分派,会踩同一个坑。
3.3 关于「无快照就进不了回顾」这个约束
用户的推理:语境句寄生在快照页面,没有快照就不该显示语境句、也不会让用户点击。方向正确,但要区分两个东西:
- 语境句存在
word_cloze_contexts表里,是独立的持久数据,与快照无关 → 照常显示(它是用户的学习记录) - 「回到这一页重温」的入口依赖可读的页面 → 处理完 §3.2 的例外后, 真正无法还原的只剩 live-only 网页(实测 3 条,2 条带词)。这类"你读的原文 已不在本地"是真实存在的 → 页面入口置灰不可点(配一句说明,不要静默无反应)
即:不可点的是页面入口,不是句子。
3.4 「返回生词本」入口 —— 需要新状态
PreFocusSnapshot(useReviewStore.ts:118-122)只存 leftPanel / rightPanel / activeTabId,不含 activeModule(模块切换归 ActivityBar)。
改法:PreFocusSnapshot 加 fromModule: ModuleId,由进入方写入 (从 Library 进则记 'library');focus chrome 顶部据此渲染「← 返回生词本」。 fromModule 缺省(正常从 ActivityBar 进 review)时不渲染该按钮。
⚠️ 依赖 §1:必须先解绑 endSession,否则"从生词本进回顾"会顺手清掉用户 原本的复习进度——把一个新入口做成了新的丢进度路径。§1 是 §3 的前置,不可颠倒。
3.5 一个待定的一致性问题
本改动会让**「首次遇见于」(进 revisit)与复习卡里的「打开原文」** (AddressBarReviewSegment.tsx:142,刻意只切 module、开普通 tab)行为分叉。
两条动线要不要统一,建议实施时一并决定,别让它成为下一处静默不一致。 倾向:保持分叉但理由写清——前者是"我要重温这一页",后者是"我要瞄一眼原文再回来答题", 确实是两种意图。
4. 任务分解
| ID | 任务 | 文件 | 量级 | 依赖 |
|---|---|---|---|---|
| M1 | switchModule 不再 endSession;endSession 只由显式结束触发 | ActivityBar.tsx、useReviewStore.ts | S | — |
| M2 | 回 review 时校验 currentCard 仍有效,失效取下一张 | useReviewStore.ts | S | M1 |
| M3 | selectedDetailId 四槽位提进 useLibraryStore | useLibraryStore.ts、VocabPanel.tsx | S | — |
| M4 | 抽 resolveRevisitUrl()(cached_file_path → rb-cache source_ref 回落);接 SentenceRevisitPanel | lib/ + 1 组件 | S | — |
| M5 | WordMemorySection 的「首次遇见于」改进 revisit;无可读 URL 时置灰 | WordMemorySection.tsx、useReviewStore.ts | M | M1, M4 |
| M6 | PreFocusSnapshot.fromModule + focus chrome「← 返回生词本」 | useReviewStore.ts + focus chrome 组件 | S | M1, M5 |
| M7 | 质量闸 /arch-check → /ui-check → /build-check → /code-review | — | — | 全部 |
⚠️ 既有测试会受影响:src/stores/reviewTabRestore.test.ts 有 5 个用例直接调 endSession() 验证 tab 还原(其中「falls back to the empty state when the snapshotted tab was closed mid-review」正是 2026-08-06 那次实测 bug 的回归锁)。M1 不改 endSession 本身的行为、只改谁来调它,所以这些用例应当全绿——若变红说明改动 超出了预期范围,停下来查,别改测试去迁就实现。
跨端:零。纯前端 store / 组件,不动 schema、sync、SM-2、预装库。零 migration。
验收:
- 复习到第 3 张卡 → 切 Library → 切回 Review → 仍是第 3 张、揭晓态保持
- Library 选中某词 → 切走 → 切回 → 仍选中该词
- 生词本「首次遇见于」→ 进 Review/revisit 且开「本页精读」右栏 → 顶部有「← 返回生词本」
- 粘贴文档在「回顾」里可正常打开(当前是"无来源")
- live-only 网页来源 → 入口置灰 + 有说明,不是静默无反应
- 复习中从生词本进 revisit 再返回 → 原复习进度仍在(§3.4 的核心风险点)
5. as-built(2026-08-12 落地记录)
M1-M7 全部完成。零 migration、零 Supabase、零 RVH(与 §4 预判一致)。
5.1 实施与计划的差异
| 项 | 计划 | 实修 |
|---|---|---|
| 切模块逻辑落点 | 改 ActivityBar.switchModule | 抽到 lib/moduleNav.ts —— 计划期只有左 rail 一个调用方,M5/M6 各带来一个,再抄一遍就是三份各自演化的模块切换规则 |
| 离开 review | 「不再 endSession」 | 不止「不做事」,还要挂起:leaveReviewModule 存复习侧现场 + 还原进来前现场(否则 Read 会拿着复习用的 webview 当可浏览 tab)。ChromeSnapshot 与 PreFocusSnapshot 互为往返 |
| 卡侧栏 | 计划未提 | 必须改「挂载 ≠ 可见」:ReviewSession 的 cards/currentIndex/revealed 是 local state,只改 store 不改挂载条件,进度照样从头开始(§1 的目标根本达不到)。代价:它在别的模块里仍挂载着,←/→ 监听必须再判 activeModule,否则浏览网页时方向键被劫走 |
| §1.4 卡片校验 | 「失效则静默取下一张」 | 拆出不计数的 goNextCard(advanceCard = bumpReviewedCount + 它)。用户没复习它,不该进 reviewedCount |
| 显式结束 | 「需新增按钮」 | 按钮 + 必须连带切模块(endReviewAndLeave):endSession 一置假 isInFocusSession,review 模块就没有 body 了。完成页的 Done 此前正是这个下场 |
endSession* 文案 | — | 在 Sprint 2 后期 P2 被删过一轮(理由:切走就等于结束),本次回归。strings/panels/focusReview.ts 头注释已记来回原因,免得下一轮又当死代码删掉 |
5.2 §3.5 的待定项 —— 决定:保持分叉
「首次遇见于」(进 revisit)与复习卡「打开原文」(开普通浏览 tab)不统一。两者是不同意图: 前者「我要重温这一页」(带着当时的痕迹进去),后者「瞄一眼原文再回来答题」。理由写进 lib/moduleNav.openRevisitPage 头注释,防止后来者当成漏改去"修正"。
5.3 §3.3 的代价(已知、已接受)
live-only 网页来源失去了旧 resolveTarget 的「开今天的实时页面」回落(旧行为:无快照但 source_type==='web' 且 URL 是 http → 开 live 页)。现在一律置灰 + sourceUnavailableTitle。 这是 §3.3 明确选择的结果——新动作是「重温」,live 页给不了当时的痕迹。若日后想两者都要, 应做成两个入口而非让一个入口语义漂移。
5.4 M7 质量闸结果
/arch-check:硬规则 6/6 过;i18n 三件套(S19/S22/S23)0/0/0;无死 store;S18=6 / S24=7 持平基线/ui-check:U1=2/2(永久豁免)· U2=0 · U3=0 · U4 无上升;未碰 chrome/toolbar,U7-U9 不适用/build-check:pnpm build过;214/214 测试全绿,含 §4 点名的 5 个endSession回归锁(按预期全绿,未改测试去迁就实现)/code-review:发现并修掉 1 处 P1 —— 指定重温页找不到时静默落到第 1 页(通路:get_word_memory对reading_notes是 LEFT JOIN,而清单源get_revisit_sources是 INNER JOIN, 悬挂note_id的孤儿页在生词本可点却不在清单里;本机实测 0 条,属潜伏)。改为明确nosource空态,并把「消费 target」提到pagesForView判空之前(否则本组为空时 target 滞留、 下次进别的组被误消费)。修后重跑 build + 214 测试仍全绿
5.5 未做的验收项
§4 验收 1-6 全部是需要真机点的动线,本轮只做到静态/单测层(新增 4 个 store 往返用例钉住 挂起/恢复不丢会话)。tauri dev 手动走一遍 1-6 仍建议做,尤其:
- 验收 5(live-only 置灰)—— 本机两个库都没有 live-only 带词来源,构造不出来
- 验收 6(复习中从生词本进 revisit 再返回,原进度仍在)—— §3.4 点名的核心风险点
5.6 真机验证结果(2026-08-12,rb-debug 驱动)
在 dev app 上实走 §4 验收动线,抓出两个静态闸门与单测都看不见的缺陷(记录在此,因为 两者的根因都是"判据选错"而非笔误,同类改动容易重犯)。
| 验收 | 结果 | 说明 |
|---|---|---|
| 1 复习中切走再回来 | ✅ | 卡片仍是 rainforest 且仍停在揭晓面。后者是关键证据:revealed 是 ReviewSession 的 local state,组件被卸载重挂就会翻回正面。顺带验证挂起时 activeTabId 还给了阅读区("点阅读卡住"的守卫) |
| 2 Library 选中跨模块保留 | ⚠️→✅ | 首轮失败,见下 ① |
| 3 「首次遇见于」进重温 | ⚠️→✅ | 首轮部分失败,见下 ②。rightPanel=annotations + 「← 返回生词本」渲染均正确 |
| 4 粘贴文档可打开 | ⛔ 无法验证 | 本机 0 个 source_type='text' 页(且 0 个 cached_file_path 为空的页)——构造不出来 |
| 5 live-only 置灰 | ⛔ 无法验证 | 同上:所有页都有快照 |
| 6 复习中经新入口绕一圈回来 | ✅ | 走完 Library→首次遇见于→重温→返回生词本→Library→Review→复习,卡片仍在揭晓面。§3.4 核心风险点通过 |
① isFirstLoadRef 判据被 StrictMode 打穿(VocabPanel.tsx 三个 tab)。 原修法用「是不是首次挂载」决定要不要清 selectedDetailId。StrictMode 把挂载 effect 跑 两遍(setup→cleanup→setup,同一实例、ref 不重置):第一遍把 ref 置 false, 第二遍就把跨模块刚恢复的选中清掉了。prod 只跑一遍所以不复现——这类"dev 坏 prod 好" 的差异最难查,且判据本身脆:日后谁往 load 的依赖里加一个 store 派生值,prod 也会每次 返回都清。改为按 queryKey(筛选条件快照)比对,对重复调用天然幂等。
② 我在 code-review 阶段的修复本身引入了更糟的行为(SentenceRevisitPanel.tsx)。 为了防 target 滞留,我把「消费 target」提到了 pagesForView 判空之前——但 pagesForView 在 sources 还是 null 的首帧天然为空,于是首帧就消费掉 target 并立刻 判定"找不到" → 报 nosource;等数据到了 target 已是 null,只能走回落页。单页库里回落 恰好是同一页,所以静态检查、单测、甚至肉眼都看不出;多页库上就是"点 A 页却打开 B 页" 外加闪一下 nosource。判据应是「加载完了没有」(sources !== null)而不是「列表为空」。
教训:
nosource这个信号是靠reviewEmptyReason粘性字段发现的,而它只由showReviewEmptyState改写、navigateRevisitTab不重置——所以"当前值是 nosource" 并不证明本次触发。判定要先把它复位成picker(切「重温」且未选组)再重跑动线。
未覆盖:←/→ 切语境的 activeModule 守卫没验到——当前卡只有 1 条语境, contextCount <= 1 时该 effect 根本不注册。需要一个多语境词才能验。
5.7 补验:粘贴文档 + 目标页判别(2026-08-12,用户现场粘了两篇)
用户当场粘入两篇文本(同属「粘贴文本」笔记,各带 2 个词),补验 §5.6 里 ⛔ 的两条。
验收 4 = ✅ 能打开,但这组数据验不出「修的是什么」。 新粘的 text 页 cached_file_path有值(text/<id>.html),而 cacheUrl('text/<id>.html') 恰好 等于 source_ref (rb-cache://localhost/text/<id>.html)—— 两条分支输出同一字符串,旧代码同样能打开。 即 §3.2 说的「21 条里 10 条 cached_file_path 为空」是历史数据形状,当前粘贴流程总会 写这一列,那个触发条件用现在的代码构造不出来。回落分支仍是对的防御(覆盖旧行 / 该列 因故为空的行),但本轮没有真正验证过它——别把 §5.7 的 ✅ 当成回落分支已验。
意外收获:验收 3 的「落到指定那一页」这下才真正可判别。 §5.6 时全库只有 1 页, 回落页与目标页是同一页,测不出区别。现在「粘贴文本」笔记下有 2 页,且清单按 COALESCE(last_opened_at, created_at) DESC 排序 → 回落页(page[0]) = 较新的 "The Trump administration…"。点较旧那页的词(democratic)的「首次遇见于」:
- review tab 落到
text/6ad69839…(= "Here's the latest",目标页) - 不是
text/7729ca1b…(回落页) reviewEmptyReason全程保持picker→ §5.6② 的 nosource 分支未触发- 清单里高亮的是第二行(目标页)而非第一行 →
active判定同样走resolveRevisitUrl
结论:target 匹配赢过回落,§5.6② 的修复得到判别性验证。
仍未验:live-only 置灰(验收 5,所有页都有快照);←/→ 的 activeModule 守卫 (需一个多语境词)。两条都是「构造不出触发条件」,不是「验了没通过」。
5.8 补验:←/→ 守卫 + currentIndex 保持(2026-08-12,用户授权点卡片)
§5.6/§5.7 遗留的两条,用户指出 rainforest/observe 现已各有 2 条语境(§5.6 那次 dump 时 只有 1 条,语境是之后加的)并授权评分,两条都补上了。
① ←/→ 的 activeModule 守卫 = ✅ 判别性通过。
设计上有个坑:handleSwitchContext 在边界夹紧(next<0 || next>=length 直接 return), 所以单次往返读不出结论——"守卫生效"与"守卫失效但被夹住"终态相同。必须分两步、每步各探一次:
| 步 | 动作 | 结果 |
|---|---|---|
| 处理 | 在 Library 里(先点空白处避开搜索框)按 → 2~3 下 | 语境 1 / 共 2 不变 ✅ |
| 对照 | 回 Review 按 → 一下 | 语境 2 / 共 2 ✅ |
对照组不可省:只有它能证明按键确实到达了 app、监听器本身工作正常,否则处理组"什么都没 发生"什么也证明不了。另注意必须先点空白处——输入框聚焦时处理器本来就早退(INPUT/ TEXTAREA/contentEditable 判据),停在搜索框里按会得到一个假通过。
探针用的是 ClozeRevealCard 圆点组的
aria-label(span[role="group"][aria-label^="语境"]), 它直接编码activePositionIdx + 1,比 dump 整个 review store 便宜得多。
② currentIndex 保持 = ✅ 通过(这是「挂载 ≠ 可见」最有力的验证)。
此前无法验:要让 currentIndex > 0 必须真的评分(写 SM-2)。用户授权后: 卡1 rainforest 点 Hard → 推进到卡2 monkey、进度 2/8 → 切「阅读」→ 切回「回顾」 → 仍是 monkey、仍 2/8。cards 数组与 currentIndex 都是 ReviewSession 的 local state,组件若被卸载,loadCards() 会重取并把 currentIndex 归 0 回到第一张。
评分写库正确(rainforest:easy_factor 2.5→1.96、interval 0→1、last_review_quality=1、 next_review 明天 —— Hard 在 repetitions=0 上的标准 SM-2 行为)。选 Hard 而非 Easy 是为了 让该词很快回到队列,对用户真实排期扰动最小。
至此仅剩验收 5(live-only 置灰)未验:需要一个"当时没存下快照"的网页来源,本机所有页 都有快照,构造不出触发条件(不是验了没通过)。