From 8495a92051d9e4873ed1d435b914e6c550592ce2 Mon Sep 17 00:00:00 2001 From: modusensus Date: Fri, 13 Nov 2026 14:30:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(panel):=20=E7=9F=A5=E8=AF=86=E5=BA=93?= =?UTF-8?q?=E4=BE=A7=E6=A0=8F=E5=85=A5=E5=8F=A3=E8=B7=9F=E7=9D=80=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E8=B5=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 2026-11-13 反馈:关掉知识库后侧栏仍留着入口。 知识库是三个可选功能里唯一图标与主座位都无条件注册的,与日记本、 仓库地志的「关着就不注册座位」不一致。这里把侧栏图标移进 features 闸(fail-closed,取不到开关就当没开),主座位保持无条件——#562 的 墙:布局里存着 activePanelId 的宿主,少注册主座位会在切面板时抛。 这反转了 #598 评论区第四节的另一半。 --- dsh-mneme/lib/client.js | 50 +++++++++++++++++++++++----------- dsh-mneme/test/client.test.js | 51 ++++++++++++++++++++++++++++------- 2 files changed, 76 insertions(+), 25 deletions(-) diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js index c29db339..51383b12 100644 --- a/dsh-mneme/lib/client.js +++ b/dsh-mneme/lib/client.js @@ -14505,16 +14505,13 @@ window.__ModuleLoader__.load({ name: "main", key: "dsh-mneme-wiki" }, () => h(WikiMainPanel, { t })); - // 知识库第三个座位(契约 §3.6)。和上面两个同理:id 与 key 逐字相同, - // 否则宿主 selectPanel(id) 在 main 槽里找不到 entry 直接抛。 - // 座位无条件注册,开关只管内容层——这样「未启用」能自己解释去哪儿开, - // 而不是让入口凭空消失(#607 的另一种写法)。 - const disposeKbGlyph = ctx.slots.register({ - name: "sidebar.panellist", - id: "dsh-mneme-kb", - order: 62, - label: () => t("kb.panel.open") - }, (props) => h(KbGlyph, { size: props && props.size })); + // 知识库第三个座位(契约 §3.6)。id 与 key 逐字相同,否则宿主 + // selectPanel(id) 在 main 槽里找不到 entry 直接抛。 + // 侧栏那一格不再无条件(#685,反转 #598 评论区 §四的另一半):开关关着 + // 时不该常驻一个点进去只有「未启用」的入口。glyph 的登记挪进下面的 + // kb 座位闸,读 features 按 kbEnabled 决定;main 仍无条件——#562 那堵墙 + // (布局里存着 activePanelId="dsh-mneme-kb" 时少注册 main,宿主 + // selectPanel 直接抛),与 wiki 座位同一处理。 const disposeKbMain = ctx.slots.register({ name: "main", key: "dsh-mneme-kb" @@ -14522,10 +14519,9 @@ window.__ModuleLoader__.load({ return () => { disposeGlyph(); disposeMain(); - // wiki glyph 不在这把扫帚里了:它的登记随座位闸走(#598), - // 生命周期归 cordis 上下文,与本回调的成对座位不同源。 + // wiki / kb 的 glyph 都不在这把扫帚里了:它们的登记随各自座位闸走 + // (#598 / #685),生命周期归 cordis 上下文,与本回调的成对座位不同源。 disposeWikiMain(); - disposeKbGlyph(); disposeKbMain(); // 座位被撤(宿主折叠该槽 / 换宿主形态)时把回落装回去:两条路径都 // 只靠各自的 inject 触发,谁不动谁就永远停在「没有入口」的状态。 @@ -14633,8 +14629,7 @@ window.__ModuleLoader__.load({ // 同一形态——features 拉一次、fail-closed(读不到就不注册,不猜)、开关 // 改动重启生效。glyph 延迟注册走 diary 右栏页签同一条 inject→register // 路径:宿主每次渲染枚举 entriesOfSlot,晚到的登记下一帧出现。 - // main 不在这里——它无条件(#562);kb 座位也不在这里——契约 §3.6 - // 刻意选了「入口常驻 + 空态自解释」的另一半(见上面 panellist 块注释)。 + // main 不在这里——它无条件(#562)。 ctx.effect(() => { let dead = false; apiFetch("/api/dsh-mneme/features") @@ -14654,6 +14649,31 @@ window.__ModuleLoader__.load({ }); return () => { dead = true; }; }, "dsh-mneme: chorography seat gate"); + + // --- 知识库侧栏座位闸(#685):与 choro 同一形态——同一个 features + // 端点、同样 fail-closed(读不到就不注册)、同样只门控侧栏那一格。 + // main 留在上面无条件注册(#562 的墙),所以「关着就不注册座位」在 + // 这里的含义是「侧栏没有入口、点不进去」,不是把 main 也摘掉:摘掉会 + // 让布局里存着 activePanelId="dsh-mneme-kb" 的宿主在 selectPanel 时抛。 + ctx.effect(() => { + let dead = false; + apiFetch("/api/dsh-mneme/features") + .then((res) => { if (!res.ok) throw new Error("HTTP " + res.status); return res.json(); }) + .then((j) => { + if (dead) return; + if (!((j && j.effective) || {}).kbEnabled) return; + ctx.slots.inject("sidebar.panellist", () => ctx.slots.register({ + name: "sidebar.panellist", + id: "dsh-mneme-kb", + order: 62, + label: () => t("kb.panel.open") + }, (props) => h(KbGlyph, { size: props && props.size }))); + }) + .catch((err) => { + console.warn("[dsh-mneme] features fetch failed, kb seat stays off:", err); + }); + return () => { dead = true; }; + }, "dsh-mneme: kb seat gate"); } exports.apply = apply; diff --git a/dsh-mneme/test/client.test.js b/dsh-mneme/test/client.test.js index 6e734de4..1c61fff0 100644 --- a/dsh-mneme/test/client.test.js +++ b/dsh-mneme/test/client.test.js @@ -116,8 +116,8 @@ test("chorography wiki main stays unconditional; the sidebar glyph is feature-ga clientSource.includes('phase: "disabled"'), "the disabled empty state must survive for layouts that still point at the wiki panel (#562 keeps main alive for exactly this)" ); - // wiki glyph 的清理随登记进了座位闸(cordis 上下文销毁时一并回收); - // panellist 回调的扫帚只剩记忆对 + wiki 的 main + kb 对。 + // wiki / kb 的 glyph 清理都随各自登记进了座位闸(cordis 上下文销毁时一并 + // 回收);panellist 回调的扫帚只剩记忆对 + 两个 main。 assert.ok( /return \(\) => \{\s*disposeGlyph\(\);\s*disposeMain\(\);[\s\S]*?disposeWikiMain\(\);/.test(clientSource), "the panellist cleanup must dispose the memory pair and wiki main, in order" @@ -393,8 +393,11 @@ test("knowledge base organises a whole repo from cards and never fakes a clean r // 知识库是第三个座位:id 与 main 的 key 必须逐字同名,否则宿主 selectPanel // 直接抛 "main panel ... is not registered"(#562 在记忆座位上踩过)。 -test("knowledge base entry registers its own seat pair in the same panellist block", () => { - assert.ok(clientSource.includes('id: "dsh-mneme-kb"'), "the kb sidebar seat must use its own id"); +// #685(反转 #598 评论区 §四的另一半):kb 的侧栏位置改成跟 kbEnabled 走, +// 与 choro 同一形态;main 仍无条件——#562 那堵墙(布局里存着 +// activePanelId="dsh-mneme-kb" 时少注册 main,宿主 selectPanel 直接抛)。 +test("knowledge base sidebar entry follows kbEnabled; its main seat stays unconditional (#685)", () => { + assert.ok(clientSource.includes('id: "dsh-mneme-kb"'), "the kb sidebar seat must keep its own id"); assert.ok(clientSource.includes('key: "dsh-mneme-kb"'), "the kb main seat key must match the sidebar id verbatim"); assert.ok(clientSource.includes("h(KbMainPanel, { t })"), "the kb main seat must render KbMainPanel"); assert.ok( @@ -402,9 +405,36 @@ test("knowledge base entry registers its own seat pair in the same panellist blo "the kb glyph takes its size from the host" ); assert.ok( - /return \(\) => \{\s*disposeGlyph\(\);\s*disposeMain\(\);[\s\S]*?disposeWikiMain\(\);\s*disposeKbGlyph\(\);\s*disposeKbMain\(\);/.test(clientSource), - "the panellist cleanup must dispose the remaining seat pairs (the wiki glyph moved to the feature gate)" + clientSource.includes('"dsh-mneme: kb seat gate"'), + "the kb glyph registration must live behind its own features gate" ); + assert.ok( + /if \(!\(\(j && j\.effective\) \|\| \{\}\)\.kbEnabled\) return;/.test(clientSource), + "the gate must fail-closed on the kbEnabled flag" + ); + assert.ok( + clientSource.includes("kb seat stays off"), + "a failed features fetch must leave the kb seat off, not guess" + ); + // 侧栏图标不能留在 panellist 回调的无条件注册里——那正是 #685 报的形态。 + const gateStart = clientSource.indexOf('ctx.slots.inject("sidebar.panellist"'); + const gateEnd = clientSource.indexOf("mountFooterSeat();", gateStart); + const panellistBlock = gateStart === -1 || gateEnd === -1 ? "" : clientSource.slice(gateStart, gateEnd); + assert.ok(panellistBlock.length > 0, "the panellist injection block must still exist"); + assert.ok( + panellistBlock.includes('key: "dsh-mneme-kb"'), + "the kb main seat must stay in the unconditional panellist block (#562)" + ); + assert.ok( + !/id:\s*"dsh-mneme-kb"/.test(panellistBlock), + "the kb sidebar glyph must not be registered unconditionally anymore (#685)" + ); + // kb glyph 的清理随登记进了座位闸;panellist 的扫帚只剩记忆对 + 两个 main。 + assert.ok( + /return \(\) => \{\s*disposeGlyph\(\);\s*disposeMain\(\);[\s\S]*?disposeWikiMain\(\);\s*disposeKbMain\(\);/.test(clientSource), + "the panellist cleanup must dispose the memory pair and both main seats (the glyphs live in their gates)" + ); + assert.ok(!clientSource.includes("disposeKbGlyph"), "the unconditional kb glyph dispose must be gone with the seat itself"); }); // v0.11.1 美化:文件清单的竖线等宽行换成真 table(用户实测反馈「文件名排列 @@ -461,8 +491,9 @@ test("panel seats: the fallback mounts and unmounts with the host's declaration" // spec 存在时点火:一把全点会把新座位的注册塞进老宿主,测出来的就不是真行为。 const fire = (ctx, key) => ctx.injected.filter((i) => i.key === key).map((i) => i.cb()); - // 新宿主:panellist 已声明。panellist + main 各三份(记忆 + chorography + - // 知识库三个座位),每对各自 id/key 逐字同名;footer 回落不装。 + // 新宿主:panellist 已声明。panellist 里只剩记忆一份图标(wiki 自 #598、 + // 知识库自 #685 都改成各自 features 闸里的按需注册),main 三份(记忆 + + // chorography + 知识库)无条件——#562 的墙;footer 回落不装。 // 日记本原有第四对座位,2026-11-09 按桉桉要求撤掉——它现在是记忆面板内的 // 一个标签页,与人格页并列,不再占侧边栏;人格页同理(面板内标签页,不是座位)。 { @@ -473,8 +504,8 @@ test("panel seats: the fallback mounts and unmounts with the host's declaration" fire(ctx, "sidebar.footer.action"); assert.equal(disposes.length, 1); const ids = (name) => regs.filter((r) => r.alive && r.opts.name === name).map((r) => r.opts.id ?? r.opts.key).sort(); - assert.equal(alive(regs, "sidebar.panellist"), 2, "memory + kb glyphs — the wiki glyph is feature-gated (#598), not unconditional"); - assert.deepEqual(ids("sidebar.panellist"), ["dsh-mneme", "dsh-mneme-kb"], "每对座位各自 id 逐字唯一;wiki glyph 走座位闸,不在无条件集合里"); + assert.equal(alive(regs, "sidebar.panellist"), 1, "memory glyph only — the wiki and kb glyphs are feature-gated (#598, #685)"); + assert.deepEqual(ids("sidebar.panellist"), ["dsh-mneme"], "the only unconditional sidebar glyph is the memory entry"); assert.equal(alive(regs, "main"), 3, "memory + wiki + kb pages — wiki main stays unconditional (#562)"); assert.deepEqual(ids("main"), ["dsh-mneme", "dsh-mneme-kb", "dsh-mneme-wiki"], "main key 与侧栏 id 逐字同名(wiki 的 main 无条件保留,#562)"); assert.equal(alive(regs, "sidebar.footer.action"), 0, "the footer fallback must not mount on a host that has the seat");