fix(settings): use the host's @deepseek-ai/schemastery so volatile prefs apply
Every side-card switch was dead: settings.update returned OK and cordis.patch.yml was written, yet the running plugin never saw the new value and nothing reported an error. Root cause: the plugin declared Config with the plain npm `schemastery` (3.18.0), which has no volatile support. The host resolves plugin config via `runtime.Config['~standard'].validate()` — i.e. the plugin's OWN schemastery — so `meta.volatile` nodes were never wrapped in cosmokit volatile references. `cordis-plugin-loader`'s `_commitVolatile()` bails out as soon as `volatileEntries(fiber.config)` is empty, so every volatile-only edit was silently dropped: no in-place update, no plugin restart. Fix: import z from '@deepseek-ai/schemastery' (peer provided by the host at mount; pinned 3.18.4 as a devDependency for the dev tree), matching the repo's existing pnpm-workspace convention for @deepseek-ai/* peers. Verified: probe reports tabsIsVolatile=true and revision climbing 0->6 across six writes, each read-back matching the written value (previously the value was frozen and revision stuck at 1). Also in this change: - apes tab ids lose their colon (apes:tasks -> apesTasks, ...): the host's settings projection dropped map keys containing a colon. - sidebar prefs ns follows the profile entry id (`better-sidebar`). - subagent rows degrade to summary rows when the root catalog never hydrates. - docs/feedback-0.2.0-rc2-contract-gaps.md: four host contract gaps, gap 4 documenting the silent volatile-write drop above. Gates: typecheck 0, lint 0, build 0, test 131 files / 1385 passed.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
# DSH 0.2.0-rc.2 插件契约缺口反馈(dsh-apes-sidebar)
|
||||
|
||||
> 反馈人:Hermes@mac13
|
||||
> 日期:2026-10-13
|
||||
> 对象:figmar(DSH 宿主团队)
|
||||
> 来源:dsh-apes-sidebar feat/apes-tab-revival 分支适配 0.2.0-rc.2 时发现的四个契约缺口
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
dsh-apes-sidebar(better-sidebar 的 fork)在适配 DSH 0.2.0-rc.2 时,遇到四个**宿主侧 API/契约缺口**,目前均以插件侧 workaround 绕过,但根因在宿主。其中**缺口 4 是「设置开关点了不生效」的真根因**,代价最大、也最隐蔽(静默失败、无任何报错)。以下逐项说明现象、根因、当前 workaround、以及期望宿主提供的正式契约。
|
||||
|
||||
---
|
||||
|
||||
## 缺口 1:settings 契约——移除 `settings.register()` 后,插件自定义 prefs schema 无注册通道
|
||||
|
||||
### 现象
|
||||
- 0.2.0-rc.2 起 `settings.register()` 被移除,改由 loader 自动注册插件导出的 `Config` schema,ns = **profile 条目的 id**(不是包名;`dsh-apes-sidebar` 这个 ns 注册不上,报 `No configurable plugin entry "dsh-apes-sidebar"`)。
|
||||
- `settings.update(ns, patch, expectedRevision)` **只放行 schema 里标了 `volatile` 的字段**(`dsh-settings` 的 `write()` → `validatePaths` → `isVolatilePath`,非 volatile 路径抛 `Config field "<path>" is not volatile`)。
|
||||
- 插件的用户偏好(tabsEnabled / autoOpenSubagent 等)原本定义在独立的 `PrefsSchema`,而 **loader 只注册 `Config`**,导致 `PrefsSchema` 成为死代码、从未被宿主认识。
|
||||
|
||||
### 当前 workaround(插件侧)
|
||||
- 把用户偏好字段**合并进 `Config`**(真正被 loader 注册的 schema),每个字段标 `.extra('volatile', true)`;部署字段(readLimit / shell 等)保持**非 volatile**。
|
||||
- 条目 id 与 settings ns 对齐为 `better-sidebar`。
|
||||
|
||||
### 期望宿主提供的契约
|
||||
1. **明确文档**:0.2.0-rc.2 起插件如何注册「用户偏好 schema」+ 如何声明字段 volatile(目前只能读 `dsh-settings` / `cosmokit` / `schemastery` 源码逆向)。
|
||||
2. **ns 解析规则文档化**:settings ns 取的是 profile 条目 id 而非包名,这一条没有任何文档,是 `No configurable plugin entry` 的唯一原因。
|
||||
3. **volatile 语义边界**:volatile 字段的约束("fixed object path without an enclosing volatile field")需文档化——尤其 `z.dict` 节点标 volatile 后,dict 内任意 key 是否都可写(实测可以,但无文档背书)。
|
||||
|
||||
---
|
||||
|
||||
## 缺口 2:`SidebarSessionsService` 缺 `openSubagent(address)` 方法
|
||||
|
||||
### 现象
|
||||
- 子代理(subagent)拓扑图里,点击一个子代理行应切到子会话。
|
||||
- better-sidebar 原版的 `openChild` 调用 `sessions.openSubagent(address)`(`address` 含 `childSessionId` + `parentSessionId`)。
|
||||
- **DSH 0.2.0-rc.2 的 `SidebarSessionsService` 没有 `openSubagent` 方法**(`context-types.ts` 声明为可选 `openSubagent?`,但宿主 runtime 未实现/未暴露)。
|
||||
- 结果:点击子代理行无反应("点不进去")。
|
||||
|
||||
### 当前 workaround(插件侧)
|
||||
- `openChild` 降级:`sessions.openSubagent(address)` 优先;宿主未暴露时回退 `sessions.open(address.childSessionId)`。
|
||||
- `sessions.open(id)` 语义是「选中一个已列会话为当前会话」,子会话在 `sessions.list` 里,选中即落地到子会话布局。
|
||||
|
||||
### 期望宿主提供的契约
|
||||
1. **实现并暴露 `openSubagent(address)`**:比 `sessions.open(childSessionId)` 更精确(带 parent 上下文,可区分同 id 不同 parent 的子会话)。
|
||||
2. **或明确文档**:若 `sessions.open(childSessionId)` 就是推荐路径,请在 `SidebarSessionsService` 的 JSDoc 注明,避免插件按 `openSubagent` 契约写代码后静默失效。
|
||||
|
||||
---
|
||||
|
||||
## 缺口 3:`subagentsByParent` 对 root 会话不 hydrate
|
||||
|
||||
### 现象
|
||||
- 子代理 catalog(`subagentsByParent`)对 **root 会话不 hydrate**,`rootCatalog` 恒为 `undefined`。
|
||||
- 宿主日志有 warning:`[agent-registry] agent/disposed listener threw: TypeError Cannot read properties of undefined (reading 'catch')`,疑打断 hydrate 流程。
|
||||
- 结果:任务管理 / 子 Agent tab 的子代理全部显示「加载中…」,永不 hydrate。
|
||||
|
||||
### 当前 workaround(插件侧)
|
||||
- 用 `sessions.list`(byId 镜像)+ `synthesizeCatalog(byId, parentId)` 按原版 `SidebarSubagentChildEntry` 契约**合成 catalog**(label 回退 `summary.displayTitle`,mode/activity/hasChildren 推断),递归补缺后再喂原版 `CatalogRows`。
|
||||
- 已生效(行可显示),但**合成数据缺少宿主实时 `activity`/`hasChildren`**,不如宿主 hydrate 精确。
|
||||
|
||||
### 期望宿主提供的契约
|
||||
1. **修复 `agent/disposed` listener 的 `catch` TypeError**:该异常疑打断 `subagentsByParent` 的 hydrate。
|
||||
2. **或提供降级数据源**:提供 `subagents.listByParent(rootId)` 返回 summary 级数据,让插件能显示「已知子代理 + 基础元数据」而非恒「加载中」。
|
||||
|
||||
---
|
||||
|
||||
## 缺口 4(真根因):插件 Config 必须用 `@deepseek-ai/schemastery`,否则 volatile 写入被 loader **静默丢弃**
|
||||
|
||||
### 现象
|
||||
- 设置页的每个开关点击后:**不报错、无 toast、但状态立刻弹回**,即「开关都不能开启或关闭」,且开关**对运行时毫无作用**。
|
||||
- 服务端 `settings.update(ns, patch, revision)` **返回成功**,profile 文档 `cordis.patch.yml` **也确实被写入**(例如 `tabsEnabled.git: false` 落盘成功)。
|
||||
- 但读回(`settings.describe().value`)与插件自身 `config` **永远不变**,且插件**既不被重启、也不被就地更新**。
|
||||
|
||||
### 根因链(逐层定位,全部有宿主源码/运行时证据)
|
||||
1. 宿主 `dsh-settings` 的 `write()` 落盘后,靠 `configEditor.edit()` → `reconcileProfilePatches()` → 最终回到 `cordis-plugin-loader` 的条目 `update()`。
|
||||
2. loader 判定 `volatileOnly`(本次改动只涉及 volatile 字段)后走 `_commitVolatile()`,其**第一行**是:
|
||||
`const refs = volatileEntries(fiber.config); if (!refs.length) return true;`
|
||||
3. `fiber.config` 里的 volatile 字段应当是 `createVolatile()` 产生的**引用对象**(形如 `Object.freeze({ get(), [Symbol.for('cosmokit.volatile.write')]() })`),而 `volatileEntries()` 只认这种引用。
|
||||
4. 这些引用由 **`schemastery` 的 `Schema.resolve()` 在遇到 `meta.volatile` 节点时创建**(`@deepseek-ai/schemastery` 的 `resolve()` 有该分支;公版 `schemastery` **没有**)。
|
||||
5. 宿主 `cordis` 的 `resolveConfig()` 走的是 **`runtime.Config["~standard"].validate()`** —— 也就是**插件自己那份 schemastery 的解析器**。
|
||||
6. 若插件声明的是 **npm 公版 `schemastery`(3.18.0,`volatile` 相关代码 0 处)**,则解析结果里**没有任何 volatile 引用** → `refs.length === 0` → `_commitVolatile()` 立即 `return true` → loader 视作「已处理」→ **既不就地更新也不重启** → 写入被彻底丢弃。
|
||||
7. 插件侧实测确认:修复前 `config.tabsEnabled` 的 `write` 符号为 `undefined`(`tabsIsVolatile=false`);改用宿主同源包后变为 `function`(`tabsIsVolatile=true`),同一套探针下 `revision` 由**恒为 1**变为**单调递增 0→6**,每次写入立即读回正确值。
|
||||
|
||||
### 当前 workaround(插件侧)
|
||||
- `src/config.ts`:`import z from 'schemastery'` → `import z from '@deepseek-ai/schemastery'`。
|
||||
- `package.json`:`dependencies.schemastery` 移除;`peerDependencies` 加 `"@deepseek-ai/schemastery": "^3.18.1"`(宿主在 mount 时提供,与本 repo `pnpm-workspace.yaml` 的既有约定一致);`devDependencies` 钉 `3.18.4` 供开发树解析。
|
||||
- 已验证:`tabsIsVolatile=true`、`rev` 单调递增、写入即时读回、门禁全绿(typecheck/lint/build/test 131 files 1385 passed)。
|
||||
|
||||
### 期望宿主提供的契约
|
||||
1. **文档化**:「声明 volatile 字段的插件必须从 `@deepseek-ai/schemastery` 导入 schemastery」——这是强制要求(宿主的 volatile 能力是 schemastery 的 fork 特性),但当前无任何文档或校验。
|
||||
2. **启动期校验(强烈建议)**:loader 加载插件时,若插件 `Config` 含 `meta.volatile` 节点,但解析后 `fiber.config` 里**不存在任何 volatile 引用**,应**报错或 warn**。现状是「静默丢弃每一次用户设置写入」,排查成本极高(需读 4 个包的源码才能定位)。
|
||||
3. **或消除版本耦合**:让 `resolveConfig` 使用宿主自己的 schemastery 解析器,而不是插件携带的那一份,从根上避免此类版本错配。
|
||||
|
||||
---
|
||||
|
||||
## 汇总
|
||||
|
||||
| # | 缺口 | 严重度 | 当前 workaround | 期望宿主动作 |
|
||||
|---|------|--------|----------------|-------------|
|
||||
| 1 | settings 契约(ns = 条目 id、volatile 无文档) | 中(已绕过) | 偏好合并进 `Config` + `.extra('volatile',true)` + ns 对齐条目 id | 文档化 ns 解析规则与 volatile 声明 |
|
||||
| 2 | 缺 `openSubagent(address)` | 高(影响核心交互) | 降级 `sessions.open(childSessionId)` | 实现 `openSubagent` 或文档化推荐路径 |
|
||||
| 3 | `subagentsByParent` root 不 hydrate + `catch` TypeError | 高(恒「加载中」) | 合成 catalog 绕过 | 修 TypeError 或提供降级数据源 |
|
||||
| 4 | **插件 Config 未用 `@deepseek-ai/schemastery` → volatile 写入被静默丢弃** | **最高(设置全部失效且无报错)** | 改用宿主同源 schemastery 包 | 文档化强制要求 + 启动期校验 + 解耦解析器 |
|
||||
|
||||
---
|
||||
|
||||
## 复现环境
|
||||
- DSH Desktop 2.0.17(Electron),宿主内置 `@deepseek-ai/dsh = 0.2.0-rc.2`、`@deepseek-ai/schemastery = 3.18.4`
|
||||
- dsh-apes-sidebar feat/apes-tab-revival(+ 本反馈对应的改动)
|
||||
- macOS 13.7.8,Node v22.22.0
|
||||
- 插件加载点:`~/.dsh/profiles/desktop/node_modules/dsh-apes-sidebar/lib/`
|
||||
- 关键源码锚点:`@deepseek-ai/cordis/lib/index.js: resolveConfig()`(`~standard.validate`)、`@deepseek-ai/cordis-plugin-loader/lib/index.js: _commitVolatile()`、`@deepseek-ai/schemastery/lib/index.cjs: Schema.resolve()`、`@deepseek-ai/dsh-settings/lib/index.js: describe()/write()`
|
||||
+2
-1
@@ -110,6 +110,7 @@
|
||||
"@deepseek-ai/dsh-settings": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-subagent": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-tools": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/schemastery": "^3.18.1",
|
||||
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
@@ -151,7 +152,6 @@
|
||||
"node-pty": "^1.1.0",
|
||||
"react-icons": "5.7.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"schemastery": "^3.18.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -182,6 +182,7 @@
|
||||
"@deepseek-ai/dsh-util-code-language": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-util-time": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "0.2.0-rc.2",
|
||||
"@deepseek-ai/schemastery": "3.18.4",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
|
||||
Generated
+3
-11
@@ -95,9 +95,6 @@ importers:
|
||||
rxjs:
|
||||
specifier: ^7.8.2
|
||||
version: 7.8.2
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: 3.18.0
|
||||
ws:
|
||||
specifier: ^8.18.0
|
||||
version: 8.21.3
|
||||
@@ -183,6 +180,9 @@ importers:
|
||||
'@deepseek-ai/dsh-util-workspace-path':
|
||||
specifier: 0.2.0-rc.2
|
||||
version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)
|
||||
'@deepseek-ai/schemastery':
|
||||
specifier: 3.18.4
|
||||
version: 3.18.4
|
||||
'@eslint/js':
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.1(eslint@10.11.0)
|
||||
@@ -3009,9 +3009,6 @@ packages:
|
||||
scheduler@0.23.0:
|
||||
resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==}
|
||||
|
||||
schemastery@3.18.0:
|
||||
resolution: {integrity: sha512-Jw2uxjoyyqc/yeurmChUEc/jbi8GsrdXV/KmqRUDZXJAXAmrJiPsz8vKa17l/VckyzljHZ9oGaul443CQiXxtA==}
|
||||
|
||||
semver@6.3.1:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
@@ -6306,11 +6303,6 @@ snapshots:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
schemastery@3.18.0:
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
cosmokit: 1.8.1
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
semver@7.8.5: {}
|
||||
|
||||
+90
-75
@@ -74,7 +74,7 @@ const JOB_KILL_ARM_MS = 3000
|
||||
* loading spinner — the session summaries are already in the mirror, so the
|
||||
* tree degrades to clickable summary rows instead.
|
||||
*/
|
||||
const CATALOG_HYDRATE_TIMEOUT_MS = 5000
|
||||
const CATALOG_HYDRATE_TIMEOUT_MS = 2500
|
||||
|
||||
/** The direct subagent children of one parent (durable `origin` rows;
|
||||
* Side Chat threads ride the same origin but are tab-strip conversations,
|
||||
@@ -160,72 +160,57 @@ function CatalogLoadingRows(props: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Degrade rows rendered when the root catalog never hydrates within
|
||||
* {@link CATALOG_HYDRATE_TIMEOUT_MS}: the session summaries are already in the
|
||||
* mirror (`byId`), so instead of pinning the page on a permanent "loading…"
|
||||
* spinner we render the known children as CLICKABLE rows that jump to the
|
||||
* child session. The address `mode` is inferred from the summary's `running`
|
||||
* flag (a running agent is continuable; a settled one is one-shot) — a
|
||||
* best-effort transport hint, since the durable catalog `mode` is unavailable
|
||||
* once hydration has given up.
|
||||
* Synthesize a durable-catalog-shaped view from the session summaries already
|
||||
* in the mirror, used ONLY as a fallback once the host never hydrates the real
|
||||
* per-parent catalog (the `summaryBackedLoading` + timeout degrade). Each
|
||||
* entry is built to the `SidebarSubagentChildEntry` contract so the OFFICIAL
|
||||
* {@link CatalogRows} renderer can consume it unchanged — live lines, mode
|
||||
* labels, diagnostic rows, nested branches and arrow-key navigation all come
|
||||
* from the stock component, not a bespoke row. `mode` is a best-effort
|
||||
* transport hint inferred from the summary's `running` flag (a running agent
|
||||
* is continuable; a settled one is one-shot); `label` is left empty so
|
||||
* {@link childLabel} falls back to the summary title.
|
||||
*/
|
||||
function SummaryFallbackRows(props: {
|
||||
parentSessionId: string
|
||||
byId: Readonly<Record<string, SidebarSessionSummary>>
|
||||
level: number
|
||||
currentSessionId: string
|
||||
openChild: (address: SidebarSubagentAddress) => void
|
||||
}) {
|
||||
const { parentSessionId, byId, level, currentSessionId, openChild } = props
|
||||
const children = directChildren(byId, parentSessionId)
|
||||
if (children.length === 0) {
|
||||
return <div className={css.subagentEmpty}>{t('loading')}</div>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{children.map(summary => {
|
||||
const address: SidebarSubagentAddress = {
|
||||
parentSessionId,
|
||||
childSessionId: summary.id,
|
||||
mode: summary.running === true ? 'continuable' : 'one-shot',
|
||||
}
|
||||
const current = summary.id === currentSessionId
|
||||
const label = summary.displayTitle !== '' ? summary.displayTitle : summary.id
|
||||
const secondary = [
|
||||
summary.displayTitle,
|
||||
summary.running === true ? t('subagentRunning') : t('subagentInactive'),
|
||||
].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<div
|
||||
key={summary.id}
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-level={level}
|
||||
aria-label={`${label} ${secondary}`}
|
||||
aria-current={current ? 'true' : undefined}
|
||||
className={clsx(css.subagentRow, current && css.subagentRowActive)}
|
||||
onClick={() => { openChild(address) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openChild(address)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StateDot
|
||||
state={summary.running === true ? 'ongoing' : 'done'}
|
||||
className={css.subagentDot}
|
||||
/>
|
||||
<span className={css.subagentContent}>
|
||||
<span className={css.subagentLabel}>{label}</span>
|
||||
<span className={css.subagentSecondary}>{secondary}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
function synthesizeCatalog(
|
||||
byId: Readonly<Record<string, SidebarSessionSummary>>,
|
||||
parentSessionId: string,
|
||||
): SidebarSubagentCatalog {
|
||||
const entries: SidebarSubagentChildEntry[] = directChildren(byId, parentSessionId).map(
|
||||
(summary): SidebarSubagentChildEntry => ({
|
||||
kind: 'child',
|
||||
id: summary.id,
|
||||
activity: summary.running === true ? 'running' : 'inactive',
|
||||
hasChildren: directChildren(byId, summary.id).length > 0,
|
||||
mode: summary.running === true ? 'continuable' : 'one-shot',
|
||||
}),
|
||||
)
|
||||
return { entries, parentAvailable: true, state: 'ready', error: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer summary-synthesized catalogs for the root and (recursively) every
|
||||
* descendant onto the host's `catalogs` map, so the stock {@link CatalogRows}
|
||||
* never meets an `undefined` child catalog during the degrade — each branch
|
||||
* expands into its synthesized children instead of a permanent "loading…".
|
||||
* Real host catalogs always win; synthesis only fills the gaps.
|
||||
*/
|
||||
function mergeSynthesizedCatalogs(
|
||||
catalogs: Readonly<Record<string, SidebarSubagentCatalog>>,
|
||||
byId: Readonly<Record<string, SidebarSessionSummary>>,
|
||||
rootId: string,
|
||||
): Readonly<Record<string, SidebarSubagentCatalog>> {
|
||||
const merged: Record<string, SidebarSubagentCatalog> = { ...catalogs }
|
||||
const seen = new Set<string>()
|
||||
const visit = (parentSessionId: string): void => {
|
||||
if (merged[parentSessionId] !== undefined || seen.has(parentSessionId)) return
|
||||
seen.add(parentSessionId)
|
||||
merged[parentSessionId] = synthesizeCatalog(byId, parentSessionId)
|
||||
for (const entry of merged[parentSessionId].entries) {
|
||||
if (entry.kind === 'child' && entry.hasChildren) visit(entry.id)
|
||||
}
|
||||
}
|
||||
visit(rootId)
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -780,7 +765,26 @@ export function SubagentView(props: {
|
||||
// highlighted) — the README "page stays open" contract.
|
||||
onOpenChild?.(address)
|
||||
try {
|
||||
sessions.openSubagent?.(address)
|
||||
// Preferred: the host's exact direct-parent jump (keeps the child's
|
||||
// own layout + the Tasks page re-open on top via `subagentJumpRef`).
|
||||
// DSH 2.0.17 hosts that haven't exposed `sessions.openSubagent` fall
|
||||
// through to a plain session open — the child still becomes the current
|
||||
// session (so the jump-back highlight fires), the node is just reached
|
||||
// without the host's topology-aware address. Without this fallback a
|
||||
// missing `openSubagent` silently swallowed the click and the child
|
||||
// was unreachable ("点不进去").
|
||||
if (typeof sessions.openSubagent === 'function') {
|
||||
sessions.openSubagent(address)
|
||||
return
|
||||
}
|
||||
// `sessions.open(id)` selects a listed session as current (mirror of
|
||||
// the runtime ISessions.open — the same mechanism the topology root
|
||||
// node uses to jump back to the main agent). The child subagent session
|
||||
// IS in `sessions.list` (the byId mirror), so selecting it lands the
|
||||
// user on the child's own layout and the jump-back highlight fires.
|
||||
if (typeof sessions.open === 'function') {
|
||||
sessions.open(address.childSessionId)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[dsh-apes-sidebar] openSubagent failed:', error)
|
||||
}
|
||||
@@ -937,15 +941,6 @@ export function SubagentView(props: {
|
||||
{summaryBackedLoading && !degraded && (
|
||||
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
|
||||
)}
|
||||
{summaryBackedLoading && degraded && (
|
||||
<SummaryFallbackRows
|
||||
parentSessionId={rootId}
|
||||
byId={byId}
|
||||
level={1}
|
||||
currentSessionId={sessionId}
|
||||
openChild={openChild}
|
||||
/>
|
||||
)}
|
||||
{!summaryBackedLoading && (
|
||||
<CatalogRows
|
||||
parentSessionId={rootId}
|
||||
@@ -959,6 +954,26 @@ export function SubagentView(props: {
|
||||
refresh={refresh}
|
||||
/>
|
||||
)}
|
||||
{/* Degraded (host never hydrated the root catalog): feed the
|
||||
session-mirror-synthesized catalog into the STOCK CatalogRows
|
||||
so the full official interaction (live lines, mode labels,
|
||||
nested branches, arrow-key nav) is reused — not a bespoke
|
||||
row. The synthesized root is merged into `catalogs` so any
|
||||
branch CatalogRows recurses into also has its own
|
||||
summary-synthesized catalog instead of a permanent loader. */}
|
||||
{summaryBackedLoading && degraded && rootId !== undefined && (
|
||||
<CatalogRows
|
||||
parentSessionId={rootId}
|
||||
catalog={synthesizeCatalog(byId, rootId)}
|
||||
catalogs={mergeSynthesizedCatalogs(catalogs, byId, rootId)}
|
||||
byId={byId}
|
||||
level={1}
|
||||
currentSessionId={sessionId}
|
||||
live={live}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{readyEmpty && (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* upstream dsh-better-sidebar. Five tabs ship (tasks / agents / messages /
|
||||
* apps / profile).
|
||||
*
|
||||
* History (C7): `apes:tasks` was removed under the old C6 decision (it looked
|
||||
* History (C7): `apesTasks` was removed under the old C6 decision (it looked
|
||||
* redundant next to the official DSH session tree). The `v2-revive` line
|
||||
* restores it — the native tree is a *navigator* (one focused lineage), this
|
||||
* panel is a *list* (every session, with an idle filter and a running badge),
|
||||
|
||||
+21
-21
@@ -5,18 +5,18 @@
|
||||
* live outside `builtins/`, so importing them does not couple the apes module
|
||||
* to upstream UI and survives cherry-picks.
|
||||
*
|
||||
* Five information-layer tabs (C7 reversal: `apes:tasks` is back). The reason
|
||||
* Five information-layer tabs (C7 reversal: `apesTasks` is back). The reason
|
||||
* the earlier C6 removal no longer holds: the native DSH session tree renders
|
||||
* ONE focused lineage, while this panel is a *flat, whole-workspace* list with
|
||||
* a running badge and an idle filter — complementary, not redundant.
|
||||
* - apes:tasks → every session (running / current / subagent marks) with
|
||||
* - apesTasks → every session (running / current / subagent marks) with
|
||||
* an idle filter; a running count badges the tab.
|
||||
* - apes:agents → the subagent topology rooted at the main agent of the
|
||||
* - apesAgents → the subagent topology rooted at the main agent of the
|
||||
* current session (rootAncestor + per-parent catalogs).
|
||||
* - apes:messages → honest placeholder: DSH exposes no message-stream feed.
|
||||
* - apes:apps → no DSH host feed today: shows the workspace (cwd) of the
|
||||
* - apesMessages → honest placeholder: DSH exposes no message-stream feed.
|
||||
* - apesApps → no DSH host feed today: shows the workspace (cwd) of the
|
||||
* current session, otherwise an honest "no feed" note.
|
||||
* - apes:profile → the current session's summary row (title, cwd, running).
|
||||
* - apesProfile → the current session's summary row (title, cwd, running).
|
||||
*
|
||||
* Live-data discipline (C4): list-reading views subscribe only while the tab
|
||||
* is on screen (`visible ? subscribe : NOOP_SUBSCRIBE`), so a collapsed panel
|
||||
@@ -85,10 +85,10 @@ const S: Record<string, CSSProperties> = {
|
||||
taskRow: { appearance: 'none', textAlign: 'left', width: '100%', cursor: 'pointer', font: 'inherit', color: 'inherit' },
|
||||
}
|
||||
|
||||
/** apes:tasks descriptor id (the plugin-toggle blob lives under this key). */
|
||||
export const TASKS_TAB_ID = 'apes:tasks'
|
||||
/** apesTasks descriptor id (the plugin-toggle blob lives under this key). */
|
||||
export const TASKS_TAB_ID = 'apesTasks'
|
||||
|
||||
/** The plugin-local switch persisted in `pluginSettings['apes:tasks']`. */
|
||||
/** The plugin-local switch persisted in `pluginSettings['apesTasks']`. */
|
||||
export const TASKS_SHOW_INACTIVE_KEY = 'showInactive'
|
||||
|
||||
/** A small colored status dot (self-contained, not the builtin StateDot). */
|
||||
@@ -135,7 +135,7 @@ function useSessionList(ctx: TabComponentProps['ctx'], visible = true): SidebarS
|
||||
|
||||
/**
|
||||
* The `showInactive` filter state. The declarative settings row writes it into
|
||||
* the sidebar prefs document under `pluginSettings['apes:tasks']` (service
|
||||
* the sidebar prefs document under `pluginSettings['apesTasks']` (service
|
||||
* contract, `SidebarSettingsDeclaration.pluginToggles`), which is NOT part of
|
||||
* the public `SidebarPrefs` type — hence the narrow local cast. Default is
|
||||
* true: the panel is a task list, so showing idle sessions is the useful
|
||||
@@ -153,7 +153,7 @@ export function tasksShowInactive(store: TabComponentProps['store']): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* apes:tasks rows: every non-side-chat session, running ones first (then the
|
||||
* apesTasks rows: every non-side-chat session, running ones first (then the
|
||||
* current one), idle ones only when `showInactive`. PURE — unit-tested.
|
||||
*/
|
||||
export function tasksRows(list: SidebarSessionList, showInactive: boolean): SidebarSessionSummary[] {
|
||||
@@ -194,7 +194,7 @@ function agentChildren(
|
||||
)
|
||||
}
|
||||
|
||||
/** apes:tasks — the flat, whole-workspace session list. */
|
||||
/** apesTasks — the flat, whole-workspace session list. */
|
||||
export function ApesTasksView(props: TabComponentProps) {
|
||||
const list = useSessionList(props.ctx, props.visible)
|
||||
const store = props.store
|
||||
@@ -244,7 +244,7 @@ export function ApesTasksView(props: TabComponentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/** apes:messages — DSH exposes no message-stream feed; honest placeholder. */
|
||||
/** apesMessages — DSH exposes no message-stream feed; honest placeholder. */
|
||||
export function ApesMessagesView(_props: TabComponentProps) {
|
||||
return (
|
||||
<div style={S.body}>
|
||||
@@ -254,7 +254,7 @@ export function ApesMessagesView(_props: TabComponentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/** apes:agents — the subagent topology of the current session's tree. */
|
||||
/** apesAgents — the subagent topology of the current session's tree. */
|
||||
function ApesAgentsView(props: TabComponentProps) {
|
||||
const list = useSessionList(props.ctx, props.visible)
|
||||
const { byId, subagentsByParent } = list
|
||||
@@ -322,7 +322,7 @@ function ApesAgentsView(props: TabComponentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/** apes:profile — the current session's identity (title, cwd, running state). */
|
||||
/** apesProfile — the current session's identity (title, cwd, running state). */
|
||||
export function ApesProfileView(props: TabComponentProps) {
|
||||
const list = useSessionList(props.ctx, props.visible)
|
||||
const current = list.current
|
||||
@@ -359,7 +359,7 @@ export function ApesProfileView(props: TabComponentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/** apes:apps — no DSH host feed today; shows the current workspace (cwd). */
|
||||
/** apesApps — no DSH host feed today; shows the current workspace (cwd). */
|
||||
function ApesAppsView(props: TabComponentProps) {
|
||||
const list = useSessionList(props.ctx, props.visible)
|
||||
const current = list.current
|
||||
@@ -383,7 +383,7 @@ function ApesAppsView(props: TabComponentProps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The five apes:* tab descriptors (C7: `apes:tasks` restored — the native
|
||||
* The five apes:* tab descriptors (C7: `apesTasks` restored — the native
|
||||
* session tree focuses one lineage, this list covers the whole workspace).
|
||||
* Order 55-70 keeps them after the built-ins (editor 10 … browser 50) and in
|
||||
* reading order: tasks / agents / apps / messages / profile. Each is already
|
||||
@@ -410,7 +410,7 @@ export const apesTabs: TabDescriptor[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'apes:agents',
|
||||
id: 'apesAgents',
|
||||
title: () => t('apesAgentsTitle'),
|
||||
icon: (size: number) => <IconAgentOutline16 size={size} />,
|
||||
order: 60,
|
||||
@@ -421,7 +421,7 @@ export const apesTabs: TabDescriptor[] = [
|
||||
return count > 0 ? count : null
|
||||
},
|
||||
},
|
||||
{ id: 'apes:apps', title: () => t('apesAppsTitle'), icon: (size: number) => <IconAppsOutline16 size={size} />, order: 65, single: true, component: ApesAppsView },
|
||||
{ id: 'apes:messages', title: () => t('apesMessagesTitle'), icon: (size: number) => <IconMessagesOutline16 size={size} />, order: 67, single: true, component: ApesMessagesView },
|
||||
{ id: 'apes:profile', title: () => t('apesProfileTitle'), icon: (size: number) => <IconUserOutline16 size={size} />, order: 70, single: true, component: ApesProfileView },
|
||||
{ id: 'apesApps', title: () => t('apesAppsTitle'), icon: (size: number) => <IconAppsOutline16 size={size} />, order: 65, single: true, component: ApesAppsView },
|
||||
{ id: 'apesMessages', title: () => t('apesMessagesTitle'), icon: (size: number) => <IconMessagesOutline16 size={size} />, order: 67, single: true, component: ApesMessagesView },
|
||||
{ id: 'apesProfile', title: () => t('apesProfileTitle'), icon: (size: number) => <IconUserOutline16 size={size} />, order: 70, single: true, component: ApesProfileView },
|
||||
]
|
||||
|
||||
+58
-4
@@ -5,7 +5,7 @@
|
||||
* @module dsh-apes-sidebar/config
|
||||
*/
|
||||
|
||||
import z from 'schemastery'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import {
|
||||
TERMINAL_FONT_SIZE_DEFAULT,
|
||||
TERMINAL_FONT_SIZE_MAX,
|
||||
@@ -28,8 +28,20 @@ export {
|
||||
type SidebarPrefs,
|
||||
} from './prefs-shared.ts'
|
||||
|
||||
/** Tunable sidebar host limits (every field optional; defaults fill in). */
|
||||
export interface SidebarConfig {
|
||||
/**
|
||||
* Tunable sidebar host limits (every field optional; defaults fill in).
|
||||
*
|
||||
* Since 0.2.0-rc.2 the loader auto-registers the plugin's `Config` schema
|
||||
* under ns = package name, and the settings service's `update()` only accepts
|
||||
* patches to fields marked `volatile`. The user-facing side-card preferences
|
||||
* (formerly the dead `PrefsSchema` export) are therefore MERGED into this
|
||||
* schema with `extra('volatile', true)` so the settings routes can persist
|
||||
* them. The deployment fields above (readLimit/shell/…) are deliberately
|
||||
* NON-volatile: they are set from `cordis.patch.yml` / profile config, not
|
||||
* from the user's settings page, and must not be writable through
|
||||
* `settings.update`.
|
||||
*/
|
||||
export interface SidebarConfig extends Partial<SidebarPrefs> {
|
||||
/** Read cap of one text file (bytes); larger files return truncated. */
|
||||
readLimit?: number
|
||||
/** Media route cap (bytes); larger binaries are refused. */
|
||||
@@ -60,8 +72,9 @@ export interface SidebarConfig {
|
||||
shellArgs?: string[]
|
||||
}
|
||||
|
||||
/** Schemastery schema for the plugin configuration. */
|
||||
/** Schemastery schema for the plugin configuration (deployment + user prefs). */
|
||||
export const Config: z<SidebarConfig> = z.object({
|
||||
// ── Deployment fields (NON-volatile; set from cordis.patch.yml / profile) ──
|
||||
readLimit: z.number().step(1).min(1).default(512 * 1024),
|
||||
mediaLimit: z.number().step(1).min(1).default(20 * 1024 * 1024),
|
||||
uploadLimit: z.number().step(1).min(1).default(128 * 1024 * 1024),
|
||||
@@ -70,6 +83,47 @@ export const Config: z<SidebarConfig> = z.object({
|
||||
reconnectGraceMs: z.number().step(1).min(0).default(30_000),
|
||||
shell: z.string().default(''),
|
||||
shellArgs: z.array(z.string()).default([]),
|
||||
// ── User-facing side-card preferences (volatile; writable via settings.update) ──
|
||||
// 0.2.0-rc.2: the settings service's `update()` only accepts patches to fields
|
||||
// whose schema node (or an ancestor) is marked `volatile`. Every user-editable
|
||||
// preference below is tagged `extra('volatile', true)` so the /sidebar/settings
|
||||
// routes can persist them. These were formerly the dead `PrefsSchema` export;
|
||||
// merging them into `Config` (the schema the loader actually registers) is what
|
||||
// makes the settings page switches stick instead of 400 → optimistic rollback.
|
||||
autoOpenSubagent: z.boolean().default(true).extra('volatile', true),
|
||||
autoOpenJobs: z.boolean().default(true).extra('volatile', true),
|
||||
agentTerminalTools: z.boolean().default(false).extra('volatile', true),
|
||||
agentOpenTools: z.boolean().default(false).extra('volatile', true),
|
||||
bottomPanelAutoTerminal: z.boolean().default(true).extra('volatile', true),
|
||||
terminalFontFamily: z.string().default('').extra('volatile', true),
|
||||
terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT).extra('volatile', true),
|
||||
editorExplorer: z.boolean().default(false).extra('volatile', true),
|
||||
workspaceFence: z.boolean().default(true).extra('volatile', true),
|
||||
terminalShell: z.string().default('').extra('volatile', true),
|
||||
terminalShellArgs: z.string().default('').extra('volatile', true),
|
||||
titleBarScheme: z.union([z.const('auto'), z.const('web'), z.const('preset'), z.const('custom')]).extra('volatile', true),
|
||||
titleBarPresetId: z.string().extra('volatile', true),
|
||||
customCss: z.string().extra('volatile', true),
|
||||
titleBarCompat: z.boolean().default(false).extra('volatile', true),
|
||||
titleBarStripPx: z.number().step(1).min(TITLE_BAR_STRIP_MIN).max(TITLE_BAR_STRIP_MAX).default(TITLE_BAR_STRIP_DEFAULT).extra('volatile', true),
|
||||
htmlViewerNoSandbox: z.boolean().default(false).extra('volatile', true),
|
||||
htmlViewerDefaultUnsafe: z.boolean().default(false).extra('volatile', true),
|
||||
browserNoSandbox: z.boolean().default(false).extra('volatile', true),
|
||||
browserInterceptLinks: z.boolean().default(true).extra('volatile', true),
|
||||
browserInterceptHttp: z.boolean().default(true).extra('volatile', true),
|
||||
browserInterceptHttps: z.boolean().default(false).extra('volatile', true),
|
||||
browserAllowedLoopback: z.string().default('').extra('volatile', true),
|
||||
// Per-feature enable switches are OPEN maps (any tab/viewer id, built-in or
|
||||
// external): an absent key means enabled, so old documents resolve to {}
|
||||
// (everything on) with no migration. Non-boolean values fail validation.
|
||||
// The whole dict node is volatile, so any key beneath it is writable.
|
||||
tabsEnabled: z.dict(z.boolean()).default({}).extra('volatile', true),
|
||||
viewersEnabled: z.dict(z.boolean()).default({}).extra('volatile', true),
|
||||
// Plugin-owned settings blobs (v0.12.0+) are an OPEN nested map: any
|
||||
// descriptor id may carry any JSON-serializable values. This is the
|
||||
// "settings seam" opening — without it the seam would drop third-party
|
||||
// keys as unknown schema fields.
|
||||
pluginSettings: z.dict(z.dict(z.any())).default({}).extra('volatile', true),
|
||||
})
|
||||
|
||||
/** Fully defaulted sidebar host settings. */
|
||||
|
||||
+5
-2
@@ -6,8 +6,11 @@
|
||||
* the browser bundle never pulls the schema runtime in.
|
||||
*/
|
||||
|
||||
/** The user-settings namespace holding the side card preferences. */
|
||||
export const SIDEBAR_PREFS_NS = 'dsh-apes-sidebar'
|
||||
/** The user-settings namespace holding the side card preferences.
|
||||
* 0.2.0-rc.2: loader registers the ns from `settings.section` id (not
|
||||
* package.json name), which is 'better-sidebar' (see client/index.tsx:482).
|
||||
* Must match or settings.update throws "No configurable plugin entry". */
|
||||
export const SIDEBAR_PREFS_NS = 'better-sidebar'
|
||||
|
||||
/** User-facing side card preferences. */
|
||||
export interface SidebarPrefs {
|
||||
|
||||
+13
-13
@@ -2,7 +2,7 @@
|
||||
* apes:* tab registration tests (Phase 1/1b → C7: 5 tabs). The fork registers
|
||||
* its information-layer tabs (tasks / agents / apps / messages / profile)
|
||||
* through the same BetterSidebarService the upstream builtins use (dogfood).
|
||||
* `apes:tasks` is back (C7 reversal of C6): the native DSH session tree focuses
|
||||
* `apesTasks` is back (C7 reversal of C6): the native DSH session tree focuses
|
||||
* ONE lineage, while this panel is a flat, whole-workspace list with a running
|
||||
* badge and an idle filter — the overlap is complementary.
|
||||
*
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
tasksShowInactive,
|
||||
} from '../src/client/apes/tabs.tsx'
|
||||
|
||||
const APES_TAB_IDS = ['apes:agents', 'apes:apps', 'apes:messages', 'apes:profile', 'apes:tasks']
|
||||
const APES_TAB_IDS = ['apesAgents', 'apesApps', 'apesMessages', 'apesProfile', 'apesTasks']
|
||||
|
||||
function setup(): {
|
||||
service: ReturnType<typeof createBetterSidebarService>
|
||||
@@ -119,10 +119,10 @@ describe('apes:* tab registrations', () => {
|
||||
try {
|
||||
const orders = {
|
||||
tasks: service.getTab(TASKS_TAB_ID)?.order,
|
||||
agents: service.getTab('apes:agents')?.order,
|
||||
apps: service.getTab('apes:apps')?.order,
|
||||
messages: service.getTab('apes:messages')?.order,
|
||||
profile: service.getTab('apes:profile')?.order,
|
||||
agents: service.getTab('apesAgents')?.order,
|
||||
apps: service.getTab('apesApps')?.order,
|
||||
messages: service.getTab('apesMessages')?.order,
|
||||
profile: service.getTab('apesProfile')?.order,
|
||||
}
|
||||
// C8: order 40-50 places apes tabs immediately after the native builtins
|
||||
// (10-35), not in a far-away 900+ band — visual unity in the tab strip.
|
||||
@@ -149,7 +149,7 @@ describe('apes:* tab registrations', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('apes:tasks plugin toggles', () => {
|
||||
describe('apesTasks plugin toggles', () => {
|
||||
it('declares the showInactive switch under the tasks descriptor', () => {
|
||||
const descriptor = apesTabs.find(d => d.id === TASKS_TAB_ID)
|
||||
const toggles = descriptor?.settings?.pluginToggles ?? []
|
||||
@@ -166,7 +166,7 @@ describe('apes:tasks plugin toggles', () => {
|
||||
expect(tasksShowInactive(store)).toBe(true)
|
||||
})
|
||||
|
||||
it('reads the boolean from pluginSettings[apes:tasks]', () => {
|
||||
it('reads the boolean from pluginSettings[apesTasks]', () => {
|
||||
const store = {
|
||||
getPrefs: () => ({ pluginSettings: { [TASKS_TAB_ID]: { [TASKS_SHOW_INACTIVE_KEY]: false } } }),
|
||||
} as unknown as TabComponentProps['store']
|
||||
@@ -183,7 +183,7 @@ describe('apes:tasks plugin toggles', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('apes:tasks row selection (PURE)', () => {
|
||||
describe('apesTasks row selection (PURE)', () => {
|
||||
it('ranks current first, then running, then idle', () => {
|
||||
const list = snap('idle-b', [
|
||||
row('idle-a'),
|
||||
@@ -219,7 +219,7 @@ describe('apes:tasks row selection (PURE)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('apes:agents badge + topology count', () => {
|
||||
describe('apesAgents badge + topology count', () => {
|
||||
it('counts the subagents of the current session tree only', () => {
|
||||
const list = snap('main', [
|
||||
row('main'),
|
||||
@@ -251,12 +251,12 @@ describe('apes badge contract', () => {
|
||||
})
|
||||
|
||||
it('badges the agents tab with the subagent count, null when childless', () => {
|
||||
expect(badgeOf('apes:agents', snap('main', [row('main'), row('c1', { origin: 'subagent', parentId: 'main' })]), 'main')).toBe(1)
|
||||
expect(badgeOf('apes:agents', snap('main', [row('main')]), 'main')).toBeNull()
|
||||
expect(badgeOf('apesAgents', snap('main', [row('main'), row('c1', { origin: 'subagent', parentId: 'main' })]), 'main')).toBe(1)
|
||||
expect(badgeOf('apesAgents', snap('main', [row('main')]), 'main')).toBeNull()
|
||||
})
|
||||
|
||||
it('never badges the placeholder/static tabs', () => {
|
||||
for (const id of ['apes:apps', 'apes:messages', 'apes:profile']) {
|
||||
for (const id of ['apesApps', 'apesMessages', 'apesProfile']) {
|
||||
expect(apesTabs.find(d => d.id === id)?.badge, `${id} badge`).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('dsh-apes-sidebar plugin export shape', () => {
|
||||
|
||||
it('registers the side card preferences schema with the documented defaults', async () => {
|
||||
const { PrefsSchema, SIDEBAR_PREFS_NS, SIDEBAR_PREFS_DEFAULTS } = await import('../src/config.ts')
|
||||
expect(SIDEBAR_PREFS_NS).toBe('dsh-apes-sidebar')
|
||||
expect(SIDEBAR_PREFS_NS).toBe('better-sidebar')
|
||||
const resolved = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})(undefined)
|
||||
|
||||
+12
-12
@@ -11,7 +11,7 @@ import { join, resolve as resolvePath } from 'node:path'
|
||||
import { SettingsConflictError, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { apply, mediaTypeForPath, wsCloseReasonOf } from '../src/index.ts'
|
||||
import { SidebarError } from '../src/wire.ts'
|
||||
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
|
||||
import { SIDEBAR_PREFS_DEFAULTS, SIDEBAR_PREFS_NS } from '../src/prefs-shared.ts'
|
||||
import { encodeHtmlUrl } from '../src/html-route.ts'
|
||||
import * as git from '../src/git.ts'
|
||||
import { listDirectory } from '../src/fs-tree.ts'
|
||||
@@ -839,11 +839,11 @@ describe('side card settings routes', () => {
|
||||
// 0.2.0-rc.2: the plugin's Config schema is auto-registered by the loader.
|
||||
// The mock pre-seeds the sidebar namespace so describe() returns it even
|
||||
// when the plugin no longer calls register() explicitly.
|
||||
if (!pre || !('dsh-apes-sidebar' in pre)) {
|
||||
if (!pre || !(SIDEBAR_PREFS_NS in pre)) {
|
||||
// Use SIDEBAR_PREFS_DEFAULTS as the resolved value for the sidebar ns:
|
||||
// the real SettingsForms service applies the Config schema defaults
|
||||
// when no stored value exists, which is exactly what this constant is.
|
||||
namespaces.set('dsh-apes-sidebar', { schema: (input: unknown) => input ?? SIDEBAR_PREFS_DEFAULTS, value: undefined, revision: 0 })
|
||||
namespaces.set(SIDEBAR_PREFS_NS, { schema: (input: unknown) => input ?? SIDEBAR_PREFS_DEFAULTS, value: undefined, revision: 0 })
|
||||
}
|
||||
for (const [ns, value] of Object.entries(pre ?? {})) {
|
||||
namespaces.set(ns, { schema: (input: unknown) => input ?? {}, value, revision: 0 })
|
||||
@@ -979,7 +979,7 @@ describe('side card settings routes', () => {
|
||||
|
||||
it('shell.get reflects the settings-page override with the quotes stripped', async () => {
|
||||
const route = mountWithSettings(createFakeSettings({
|
||||
'dsh-apes-sidebar': {
|
||||
[SIDEBAR_PREFS_NS]: {
|
||||
terminalShell: '"C:\\Program Files\\PowerShell\\7\\pwsh.exe"',
|
||||
terminalShellArgs: '-NoLogo',
|
||||
},
|
||||
@@ -1184,7 +1184,7 @@ describe('agent terminal tool gating', () => {
|
||||
const handlerRef: { current: ((ns: string) => void) | null } = { current: null }
|
||||
const settings = {
|
||||
describe: () => [{
|
||||
ns: 'dsh-apes-sidebar',
|
||||
ns: SIDEBAR_PREFS_NS,
|
||||
value: { ...SIDEBAR_PREFS_DEFAULTS, agentTerminalTools: enabled },
|
||||
applies: 'live' as const,
|
||||
revision: 0,
|
||||
@@ -1215,18 +1215,18 @@ describe('agent terminal tool gating', () => {
|
||||
expect(live()).toBe(0)
|
||||
// Flipping the setting on registers all eight tools.
|
||||
enabled = true
|
||||
handlerRef.current?.('dsh-apes-sidebar')
|
||||
handlerRef.current?.(SIDEBAR_PREFS_NS)
|
||||
expect(live()).toBe(8)
|
||||
expect(disposed).toBe(0)
|
||||
// Flipping it back off unregisters them (and releases any agent terminals).
|
||||
enabled = false
|
||||
handlerRef.current?.('dsh-apes-sidebar')
|
||||
handlerRef.current?.(SIDEBAR_PREFS_NS)
|
||||
expect(live()).toBe(0)
|
||||
expect(disposed).toBe(8)
|
||||
// And a redundant toggle registers them fresh (no double-registration per
|
||||
// flip: the guard only skips when the tools are already live).
|
||||
enabled = true
|
||||
handlerRef.current?.('dsh-apes-sidebar')
|
||||
handlerRef.current?.(SIDEBAR_PREFS_NS)
|
||||
expect(live()).toBe(8)
|
||||
expect(registered).toBe(16)
|
||||
})
|
||||
@@ -1241,7 +1241,7 @@ describe('agent sidebar-open tool gating', () => {
|
||||
const handlerRef: { current: ((ns: string) => void) | null } = { current: null }
|
||||
const settings = {
|
||||
describe: () => [{
|
||||
ns: 'dsh-apes-sidebar',
|
||||
ns: SIDEBAR_PREFS_NS,
|
||||
value: { ...SIDEBAR_PREFS_DEFAULTS, agentOpenTools: enabled },
|
||||
applies: 'live' as const,
|
||||
revision: 0,
|
||||
@@ -1272,17 +1272,17 @@ describe('agent sidebar-open tool gating', () => {
|
||||
expect(live()).toBe(0)
|
||||
// Flipping the setting on registers the single sidebar_open tool.
|
||||
enabled = true
|
||||
handlerRef.current?.('dsh-apes-sidebar')
|
||||
handlerRef.current?.(SIDEBAR_PREFS_NS)
|
||||
expect(live()).toBe(1)
|
||||
expect(disposed).toBe(0)
|
||||
// Flipping it back off unregisters it (and drains the undelivered queue).
|
||||
enabled = false
|
||||
handlerRef.current?.('dsh-apes-sidebar')
|
||||
handlerRef.current?.(SIDEBAR_PREFS_NS)
|
||||
expect(live()).toBe(0)
|
||||
expect(disposed).toBe(1)
|
||||
// And a redundant toggle registers it fresh (no double-registration).
|
||||
enabled = true
|
||||
handlerRef.current?.('dsh-apes-sidebar')
|
||||
handlerRef.current?.(SIDEBAR_PREFS_NS)
|
||||
expect(live()).toBe(1)
|
||||
expect(registered).toBe(2)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user