fix(subagent): hydrate the catalog via the host's refreshProjections
0.2.0-rc.2 withdrew every catalog/subagent seam this fork was written against, and all three calls degraded to silent no-ops: openSubagent(address) -> gone setSubagentCatalogOpen(id, open) -> gone refreshSubagents(id) -> gone The host's own subagent UI (dsh-client-ui-subagent) rebuilds a parent's child catalog with `sessions.refreshProjections(parentSessionId)`, so that is the only kick that still exists. Wire it into `observe()` and the catalog retry button, keeping the legacy names as fallbacks for older hosts. Without it the root catalog never hydrated: the page sat on "loading" or degraded to synthesized summary rows. Also document what the investigation settled: - docs/subagent-state-enumeration.md: the full state model (catalog child / diagnostic / session / job / sidechat) and proof that this fork already renders it 1:1 with upstream. - docs/plugin-conventions.md: the hard rules another sidebar plugin must follow (host schemastery, volatile prefs, ns = profile entry id, no colons in tab ids, never read a volatile field directly) plus the withdrawn-API replacement table and a pre-release checklist. - docs/feedback-0.2.0-rc2-contract-gaps.md: gaps 2/3 reframed. The ask is documenting the official paths (open(childSessionId) / refreshProjections(id)), not adding new APIs. Gates: typecheck 0, lint 0, build 0, test 131 files / 1385 passed.
This commit is contained in:
@@ -100,12 +100,34 @@ dsh-apes-sidebar(better-sidebar 的 fork)在适配 DSH 0.2.0-rc.2 时,遇
|
||||
| # | 缺口 | 严重度 | 当前 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 或提供降级数据源 |
|
||||
| 2 | 缺 `openSubagent(address)` | 高(影响核心交互) | 降级 `sessions.open(childSessionId)` | 文档化推荐路径(官方路径见下方补充) |
|
||||
| 3 | `subagentsByParent` root 不 hydrate + `catch` TypeError | 高(恒「加载中」) | 合成 catalog 绕过 + 改用 `sessions.refreshProjections` | 修 TypeError 或文档化 `refreshProjections` |
|
||||
| 4 | **插件 Config 未用 `@deepseek-ai/schemastery` → volatile 写入被静默丢弃** | **最高(设置全部失效且无报错)** | 改用宿主同源 schemastery 包 | 文档化强制要求 + 启动期校验 + 解耦解析器 |
|
||||
|
||||
---
|
||||
|
||||
## 补充(2026-10-13 晚,从宿主自身代码学到的官方路径)
|
||||
|
||||
在上面三个替代 API 都 0 命中的同时,宿主**自己的**子代理 UI(`@deepseek-ai/dsh-client-ui-subagent/lib/client.js:945-1005`)给出了官方正确姿势:
|
||||
|
||||
```js
|
||||
const catalogActions = (_parentSessionId) => ({
|
||||
openChild(address) { ctx.uiWorkspace.openSession(address) }, // 传 address(多态 target)
|
||||
openChildAside(address) { ctx.sidebarRight.openResource(subagentChatAddress(address),
|
||||
{ kind: 'subagentchat', preferNewPane: true }) },
|
||||
refreshProjection(id) { ctx.sessions.refreshProjections(id) }, // ← catalog 唯一 kick
|
||||
})
|
||||
```
|
||||
|
||||
链路:`uiWorkspace.openSession(target)` → `replaceMain(target)` → `sessions.retain(target)`,**target 接受 SessionId 或 SubagentAddress**,且**没有子会话守卫**。
|
||||
|
||||
⇒ **缺口 2/3 的正确诉求不是「补 API」,而是「文档化这两条官方路径」**:
|
||||
① 切到子会话 = `sessions.open(childSessionId)`(等价于官方的 `openSession(target)`);
|
||||
② 让宿主 hydrate 某个 parent 的 catalog = `sessions.refreshProjections(parentId)`(**唯一**的 kick,旧名 `refreshSubagents`/`setSubagentCatalogOpen` 已删除且不报错)。
|
||||
另外官方还有一条我们**未接入**的能力:把子会话聊天作为原生右侧栏资源打开(`sidebarRight.openResource(..., {kind:'subagentchat'})`),建议一并文档化。
|
||||
|
||||
---
|
||||
|
||||
## 复现环境
|
||||
- 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(+ 本反馈对应的改动)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# 侧边栏插件操作规范(给其他插件的 Agent)
|
||||
|
||||
> 版本基线:DSH Desktop 2.0.17 / 宿主 `@deepseek-ai/*` 0.2.0-rc.2
|
||||
> 用途:任何往 DSH 右侧栏挂 tab 的插件都按这份规范做,避免重复踩坑(每条都有实测根因)
|
||||
|
||||
## 一、硬规则(违反 = 静默失效,不报错)
|
||||
|
||||
### 1. `Config` 必须用宿主的 schemastery ⭐ 头号坑
|
||||
```ts
|
||||
import z from '@deepseek-ai/schemastery' // ✅ 宿主 fork,含 volatile 支持
|
||||
import z from 'schemastery' // ❌ 公版 3.18.0,无 volatile
|
||||
```
|
||||
- 后果:`settings.update` **写进 `cordis.patch.yml` 成功**,但宿主 loader 静默 no-op —— 不生效、不报错、不重启、值冻结。
|
||||
- 机制:宿主 `cordis.resolveConfig` 走 `runtime.Config['~standard'].validate()`,用的是**插件自己那份 schemastery** 的解析器;无 volatile 分支 ⇒ 没有 volatile 引用 ⇒ `cordis-plugin-loader._commitVolatile()` 首行 `if (!volatileEntries(fiber.config).length) return true` ⇒ 视作已提交。
|
||||
- `package.json`:`peerDependencies` 加 `"@deepseek-ai/schemastery": "^3.18.1"`(宿主 mount 时提供),`devDependencies` 钉 `3.18.4`。
|
||||
|
||||
### 2. 用户偏好字段必须标 volatile,且并入 `Config`
|
||||
```ts
|
||||
tabsEnabled: z.dict(z.boolean()).default({}).extra('volatile', true)
|
||||
```
|
||||
- 0.2.0+ 移除了 `settings.register()`,**loader 只注册插件导出的 `Config`**;独立 `PrefsSchema` 是死代码。
|
||||
- `settings.update` **只放行 volatile 字段**(非 volatile 路径抛 `Config field "<x>" is not volatile`)。
|
||||
- **部署字段**(limit / shell 之类)保持**非 volatile**——它们不该被设置页改写。
|
||||
|
||||
### 3. settings ns = **profile 条目 id**,不是包名
|
||||
`cordis.patch.yml` 里 `- id: <ns>` 的那个 id。用包名会在 `settings.describe()` 里找不到自己(报 `No configurable plugin entry "<包名>"`)。
|
||||
|
||||
### 4. tab id / map key **不要用冒号**
|
||||
`apesTasks` ✅ / `apes:tasks` ❌。冒号 key 在设置投影链路上出现过丢失。
|
||||
(i18n key 不受影响,本来就是 camelCase。)
|
||||
|
||||
### 5. volatile 字段**不能直读**
|
||||
修复后 `config.tabsEnabled` 是 `createVolatile()` 产出的**不透明引用**:
|
||||
```js
|
||||
Object.freeze({ get: () => current, [Symbol.for('cosmokit.volatile.write')]: v => { current = v } })
|
||||
```
|
||||
⇒ 没有数据属性,`config.tabsEnabled.git` 是 `undefined`(不是旧值),`JSON.stringify` 出 `{}`。
|
||||
**必须走 `settings.get()` / `settings.describe()`**(宿主对 ref 调 `plainConfig()` 解包)。
|
||||
|
||||
## 二、0.2.0-rc.2 消失的宿主 API 与官方替代
|
||||
|
||||
| 老调用(会静默 no-op) | 官方替代(宿主自己的 UI 就是这么调的) |
|
||||
|---|---|
|
||||
| `sessions.openSubagent(address)` | `sessions.open(childSessionId)` |
|
||||
| `sessions.setSubagentCatalogOpen(id, open)` | `sessions.refreshProjections(id)` |
|
||||
| `sessions.refreshSubagents(id)` | `sessions.refreshProjections(id)` |
|
||||
|
||||
判定方法(别猜):在 app 内全量 grep 宿主包——
|
||||
```bash
|
||||
A="/Applications/DSH Desktop.app/Contents/Resources/app/node_modules/@deepseek-ai"
|
||||
grep -rn "<apiName>" "$A"/*/lib/*.js | head
|
||||
# 0 命中 = 宿主没实现;再看官方自己的插件怎么调的(如下):
|
||||
sed -n '945,1005p' "$A/dsh-client-ui-subagent/lib/client.js"
|
||||
```
|
||||
`dsh-client-ui-subagent` 是**宿主自己的子代理 UI**,是找「官方正确姿势」的最权威样板。
|
||||
|
||||
## 三、注册/生命周期
|
||||
|
||||
- 服务注册必须等服务、不能等槽声明:`ctx.inject(['sidebarRightTabs'], cb)` 驱动类型注册(槽声明早于服务 provide,按槽注册会静默什么都不注册且不重试);槽只用来挂 tab 体。
|
||||
- 原生 tab 体宿主 `.paneBody` 是**有确定高度的块级滚动容器**,native 适配层要给每个 tab 体包 `height:100%` 的列 flex 宿主。
|
||||
- 跨会话操作原生栏用 `openTabIn/openResourceIn/closeIn`(不在 `ISidebarRight` 接口里,需结构化探测)。
|
||||
|
||||
## 四、自检清单(发版前逐条过)
|
||||
|
||||
1. `pnpm typecheck && pnpm lint && pnpm build && pnpm test` 全绿(构建 ≠ typecheck,tsdown 跳过 tsc 严格性)。
|
||||
2. `grep '^import z' lib/index.js` → 必须是 `@deepseek-ai/schemastery`。
|
||||
3. `grep -c 'schemastery' package.json` → `dependencies` 里**没有**公版 schemastery。
|
||||
4. 设置开关实测:点两次,值**不再弹回**,且 `cordis.patch.yml` 里的新值等于 UI 显示值。
|
||||
5. 改完配置**不需要重启 DSH**(volatile 就地生效);若需要重启才生效 = 第 1 条没做对。
|
||||
6. 产物 rsync 到 `~/.dsh/profiles/<profile>/node_modules/<pkg>/lib/` 后**逐文件 md5 比对**(tsdown 输出非确定性,字节数相同≠内容相同)。
|
||||
@@ -0,0 +1,72 @@
|
||||
# 子 Agent 完整状态枚举(对齐 better-sidebar 原版)
|
||||
|
||||
> 口径来源:better-sidebar 原版 `src/client/SubagentView.tsx` + `src/subagent-activity.ts`(本 fork 完整保留)
|
||||
> 宿主事实来源:`@deepseek-ai/*` 0.2.0-rc.2 真实包内代码
|
||||
> 结论:**本插件的状态渲染已 100% 对齐原版**;枚举上限由宿主字段决定,不是插件缺口。
|
||||
|
||||
## 一、三个状态域 + 一个诊断分支
|
||||
|
||||
### 域 1:子代理 catalog 行(拓扑卡片)— 主战场
|
||||
|
||||
数据源:`sessions.list.getSnapshot().subagentsByParent[parentId].entries[]`,其中 `kind: 'child'`。
|
||||
|
||||
| 字段 | 取值 | 渲染 |
|
||||
|---|---|---|
|
||||
| `activity` | `'running'` \| `'inactive'` | `StateDot state='ongoing'` \| `state='done'` |
|
||||
| `mode` | `'one-shot'` \| `'continuable'` | 副行文本(一次性 / 可续) |
|
||||
| `hasChildren` | `boolean` | 是否可展开(真叶子不给 `aria-expanded`) |
|
||||
| `label?` | `string` | 主标签;缺省回退 `summary.displayTitle`,再回退 `entry.id` |
|
||||
| `id` | `string` | 行标识 + address 组装 |
|
||||
|
||||
副行格式(原版 `cardSecondary`):`displayTitle · mode · activity`,空段跳过。
|
||||
|
||||
**running 行额外显示实时活动**:`subagents.live` 批量路由 → `LastActivity { text?, tool?{name,args} }`
|
||||
(服务端 `lastActivity()` 扫会话事件日志尾部的最后一条 assistant 文本 + 最后一次工具调用)。
|
||||
|
||||
### 域 2:catalog 行不可读 → 诊断分支
|
||||
|
||||
`kind: 'diagnostic'`(损坏 / 不受支持 / 不可用)→ `StateDot state='error'`。
|
||||
容器级状态另有 `SidebarSubagentCatalog.state: 'loading' | 'ready' | 'error'`,驱动空态行与重试按钮。
|
||||
|
||||
### 域 3:会话行(非拓扑列表)
|
||||
|
||||
`SidebarSessionSummary.running?: boolean` → `StateDot state={running ? 'ongoing' : 'done'}`。
|
||||
**注意:宿主对会话只给这一个布尔**,没有更细的状态。
|
||||
|
||||
### 域 4:后台 job 与 SideChat
|
||||
|
||||
| 面 | 字段 | 取值 → dot |
|
||||
|---|---|---|
|
||||
| 后台 job | `job.status` | `running`/`stopping`/`completed`/`killed`/`failed` → `jobDotState()` |
|
||||
| SideChat 行 | 行级 `failed` / `running` | `error` / `ongoing` |
|
||||
|
||||
## 二、StateDot 的可用状态(宿主固定,插件改不了)
|
||||
|
||||
`StateDot` 来自 `@deepseek-ai/dsh-client-ui-primitives`,**只有 3 个 state**:
|
||||
`'ongoing'`(活动)/ `'done'`(静止)/ `'error'`(失败)。
|
||||
|
||||
⇒ 插件能把 N 态数据映射到 3 个视觉态,仅此而已。**要更细的视觉必须宿主开口子**。
|
||||
|
||||
## 三、原版 vs 本 fork 对照(结论:已对齐)
|
||||
|
||||
| 面 | 原版 | 本 fork |
|
||||
|---|---|---|
|
||||
| activity → dot | `running ? 'ongoing' : 'done'` | **同** |
|
||||
| diagnostic → dot | `'error'` | **同** |
|
||||
| 副行 mode·activity | `cardSecondary` | **同** |
|
||||
| running 实时行 | `LastActivity` | **同** |
|
||||
| 会话行 dot | `running ? 'ongoing' : 'done'` | **同** |
|
||||
| job dot | `jobDotState(status)` | **同** |
|
||||
| SideChat 失败行 | `'error'` | **同** |
|
||||
|
||||
## 四、宿主侧真实缺口(不是枚举本身,而是枚举的「上游」)
|
||||
|
||||
0.2.0-rc.2 里原版依赖的**三个 API 全部消失**(app 内 `@deepseek-ai/*` 全量 grep 0 命中):
|
||||
|
||||
| 原版调用 | 0.2.0-rc.2 状态 | 官方替代 |
|
||||
|---|---|---|
|
||||
| `sessions.openSubagent(address)` | **不存在** | `sessions.open(childSessionId)`(切主面板;最终走 `replaceMain → retain`) |
|
||||
| `sessions.setSubagentCatalogOpen(id, open)` | **不存在** | `sessions.refreshProjections(id)` |
|
||||
| `sessions.refreshSubagents(id)` | **不存在** | 同上 |
|
||||
|
||||
⇒ catalog 的**来源**(宿主 hydrate)曾长期缺失,这才是「恒加载中」与「点不进去」的真根因;枚举与渲染层面本插件无缺口。
|
||||
@@ -717,6 +717,11 @@ export function SubagentView(props: {
|
||||
const observedRef = useRef(new Set<string>())
|
||||
|
||||
const observe = useCallback((parentSessionId: string, open: boolean): void => {
|
||||
// Ask the host to (re)build this parent's child catalog. The projection is
|
||||
// lazy and 0.2.0-rc.2 exposes ONLY this kick — the old subscription
|
||||
// (`setSubagentCatalogOpen`) is gone, so a consumer that called just that
|
||||
// ended up with an un-hydrated root stuck on "loading" forever.
|
||||
if (open) void sessions.refreshProjections?.(parentSessionId)
|
||||
sessions.setSubagentCatalogOpen?.(parentSessionId, open)
|
||||
if (open) observedRef.current.add(parentSessionId)
|
||||
else observedRef.current.delete(parentSessionId)
|
||||
@@ -801,7 +806,10 @@ export function SubagentView(props: {
|
||||
}, [sessions, rootId])
|
||||
|
||||
const refresh = useCallback((parentSessionId: string): void => {
|
||||
void sessions.refreshSubagents?.(parentSessionId)
|
||||
// 0.2.0-rc.2 renamed this kick to `refreshProjections` (it is what the
|
||||
// host's own subagent UI calls); preferring the old name made the catalog
|
||||
// retry button a silent no-op.
|
||||
void (sessions.refreshProjections ?? sessions.refreshSubagents)?.(parentSessionId)
|
||||
}, [sessions])
|
||||
|
||||
const totals = useMemo(
|
||||
|
||||
+13
-6
@@ -375,8 +375,9 @@ export interface SidebarSessionsService {
|
||||
*/
|
||||
scope(id: string): Context | undefined
|
||||
/**
|
||||
* Open a healthy catalog child through its exact direct-parent address
|
||||
* (mirror of the runtime ISessions.openSubagent).
|
||||
* Open a healthy catalog child through its exact direct-parent address.
|
||||
* Absent on 0.2.0-rc.2 (the runtime seam was withdrawn) — callers fall back
|
||||
* to `open(childSessionId)`.
|
||||
*/
|
||||
openSubagent?(address: SidebarSubagentAddress): void
|
||||
/**
|
||||
@@ -384,12 +385,18 @@ export interface SidebarSessionsService {
|
||||
*/
|
||||
subagentAddress?(id: string): SidebarSubagentAddress | undefined
|
||||
/**
|
||||
* Mark whether a catalog surface is consuming live membership updates.
|
||||
* Rebuild one session's lazy projections — including its subagent child
|
||||
* catalog (`subagentsByParent`). This is the ONLY catalog kick 0.2.0-rc.2
|
||||
* exposes; the host's own subagent UI calls exactly this
|
||||
* (`ctx.sessions.refreshProjections(parentSessionId)`). Without it the
|
||||
* catalog never hydrates and the root stays on "loading" forever.
|
||||
*/
|
||||
refreshProjections?(sessionId: string): Promise<void>
|
||||
/**
|
||||
* Legacy catalog surfaces from pre-0.2.0 hosts. Absent on 0.2.0-rc.2 —
|
||||
* calling them is a silent no-op, kept only so older hosts keep working.
|
||||
*/
|
||||
setSubagentCatalogOpen?(parentSessionId: string, open: boolean): void
|
||||
/**
|
||||
* Refresh one direct-child catalog.
|
||||
*/
|
||||
refreshSubagents?(parentSessionId: string): Promise<void>
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user