feat(apes): 复活信息层 tasks/messages tab(三 tab → 五 tab) #1
@@ -0,0 +1,136 @@
|
||||
# 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)` | 文档化推荐路径(官方路径见下方补充) |
|
||||
| 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', preferNewPane:true})`)——**本插件已按官方形式接入**(子代理行上的「在侧栏打开」按钮),同样建议文档化。
|
||||
|
||||
---
|
||||
|
||||
## 复现环境
|
||||
- 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()`
|
||||
@@ -0,0 +1,40 @@
|
||||
# apes 信息层 tab 复活(C6 → C7 反演)· 2026-10-11
|
||||
|
||||
## 为什么
|
||||
|
||||
C6 那次收紧把 `apes:tasks` 从信息层删掉了,理由是与宿主自带的会话面板「职责重叠」。
|
||||
实际用下来这个判断过于保守:**宿主的会话面板按工作区分组、服务于「换项目」,插件这个 tab
|
||||
服务于「此刻谁在跑」**——它把当前会话、运行中会话、子智能体三种状态压进一屏,是切会话的入口,
|
||||
不是另一份项目列表。删掉之后,「我现在有几个会话在跑」这件事在界面上就没有观测点了。
|
||||
|
||||
因此本轮按 C-2 的口径**照字面复活**:把 from-scratch 线(`apes-sidebar@2.0.0`)当年的五个
|
||||
tab 完整搬回 `dsh-apes-sidebar`,并顺手把它当年没做完的两件事补上(角标计数、隐藏门控)。
|
||||
|
||||
## 变成什么
|
||||
|
||||
| tab | order | 作用 | 相对 v2.0.0 |
|
||||
| --- | --- | --- | --- |
|
||||
| `apes:tasks` | 55 | 会话列表:当前 / 运行中 / 子智能体标记,点击切换会话 | 复活 |
|
||||
| `apes:agents` | 60 | 主代理 + 直系子代理拓扑 | 复活(沿用 1.2.0 已有的拓扑实现) |
|
||||
| `apes:apps` | 65 | 当前会话所属工作区 | 复活(宿主无应用 feed,占位) |
|
||||
| `apes:messages` | 67 | 消息流占位 | 复活(宿主无消息流 feed) |
|
||||
| `apes:profile` | 70 | 当前会话摘要(标题 / cwd / 运行态) | 复活 |
|
||||
|
||||
新增的两条行为:
|
||||
|
||||
- **角标**:`apes:tasks` 显示运行中会话数,`apes:agents` 显示直系子代理数;计数为 0 时不发角标。
|
||||
- **门控**:tab 不可见时不订阅会话 feed(不吃会话事件);`apes:tasks` 带 `showInactive` 开关,
|
||||
落在 `prefs.pluginSettings['apes:tasks']`,缺省显示全部。
|
||||
|
||||
## 与 C6 的关系(如何避免重复搬运)
|
||||
|
||||
C6 删的是「与宿主会话面板重复的那部分」;本轮复活的是「宿主没有的那部分」。判断标准不是
|
||||
「有没有相似的 UI」,而是**这件事在宿主里有没有观测点**:会话的运行态、子智能体的直系拓扑,
|
||||
宿主的两处面板都不给,插件给。据此定下取舍线——**宿主给了 feed 就不自造(apps / messages 明确
|
||||
写空态),宿主没给才自己接(tasks / agents)**。
|
||||
|
||||
## 相关
|
||||
|
||||
- 代码:`src/client/apes/tabs.tsx`、`locales.ts`(zh/en/ja)、`icons.tsx`、`src/client/index.tsx`
|
||||
- 契约:`docs/wiki/03-功能说明.md` §一 总表 / §二 信息层五 tab(行为契约)
|
||||
- 守护:`tests/apes-tabs.spec.ts`(五 tab 注册、角标、门控、过滤与排序、三语词条齐备)
|
||||
@@ -0,0 +1,87 @@
|
||||
# 侧边栏插件操作规范(给其他插件的 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**,是找「官方正确姿势」的最权威样板。
|
||||
|
||||
## 二·补、把「另一个会话的聊天」开到侧栏(官方做法)
|
||||
|
||||
不想切走主面板、又想盯着某个子会话时,用官方资源式打开(宿主自己的子代理 UI 就是这么做的):
|
||||
|
||||
```ts
|
||||
import { SUBAGENT_CHAT_KIND, subagentChatAddress } from './native/subagent-chat.ts' // 本插件提供的编解码
|
||||
|
||||
const column = (ctx as { get?: (n: string) => unknown }).get?.('sidebarRight') as
|
||||
{ openResource?: (a: string, o?: { kind?: string; preferNewPane?: boolean }) => void } | undefined
|
||||
column?.openResource?.(subagentChatAddress(address), { kind: SUBAGENT_CHAT_KIND, preferNewPane: true })
|
||||
```
|
||||
|
||||
- 地址格式(必须逐字节一致,否则宿主解析不到):`dsh-resource://subagentchat/session/<encodeURIComponent(childSessionId)>?parent=<parentSessionId>&mode=<mode>`
|
||||
- `kind: 'subagentchat'` + `preferNewPane: true` 是官方那两个参数,别省。
|
||||
- 读取服务必须**可选探测**(`ctx.get?.()`):测试里的假 ctx 没有 `get`,直接 `ctx.get(...)` 会在渲染期崩掉整棵子树(我们踩过:9 个渲染测试全挂)。
|
||||
- 交互:官方只在「该行不是当前会话」时显示按钮,并给 `title` + `aria-label` 同一份文案。
|
||||
|
||||
## 三、注册/生命周期
|
||||
|
||||
- 服务注册必须等服务、不能等槽声明:`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)曾长期缺失,这才是「恒加载中」与「点不进去」的真根因;枚举与渲染层面本插件无缺口。
|
||||
@@ -29,7 +29,7 @@
|
||||
│ · src/client/builtins/ 内置面板:文件 / 文件变动 / 子代理 / 侧边对话 / │
|
||||
│ 终端 / 浏览器 + 文件预览器 │
|
||||
│ · src/client/sidebar/ 自绘底部工作台与开合控件(原生侧边栏之外的部分) │
|
||||
│ · src/client/apes/ 信息层三 tab(本 fork 的增量) │
|
||||
│ · src/client/apes/ 信息层五 tab(本 fork 的增量) │
|
||||
│ · src/client/service.ts ctx.betterSidebar 服务(供其他插件注册) │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
+13
-4
@@ -15,23 +15,32 @@
|
||||
| `terminal` | 终端 | 40 | 上游 | PTY 终端(懒加载 `client-terminal`;v0.24.1 线起由宿主内置终端承担) |
|
||||
| `browser` | 浏览器 | 50 | 上游 | 内嵌浏览器/HTML 预览宿主 |
|
||||
| `diff` | (内部) | -1 | 上游 | 非用户可见,供内部打开 diff |
|
||||
| **`apes:agents`** | **智能体** | **60** | **本 fork** | 主代理 + 直系子代理拓扑 |
|
||||
| **`apes:tasks`** | **任务** | **55** | **本 fork** | 会话列表(当前 / 运行中 / 子智能体标记,`showInactive` 开关;角标 = 运行中会话数) |
|
||||
| **`apes:agents`** | **智能体** | **60** | **本 fork** | 主代理 + 直系子代理拓扑(角标 = 直系子代理数) |
|
||||
| **`apes:apps`** | **应用** | **65** | **本 fork** | 当前会话所属工作区(宿主暂无「应用」feed) |
|
||||
| **`apes:messages`** | **消息** | **67** | **本 fork** | 消息流占位(宿主暂无消息流 feed) |
|
||||
| **`apes:profile`** | **画像** | **70** | **本 fork** | 当前会话摘要(标题 / cwd / 运行态) |
|
||||
|
||||
> order 10–50 是上游/宿主既定带宽,apos 信息层取 60/65/70 留出余量,避免与原生面板抢占位置。
|
||||
> order 10–50 是上游/宿主既定带宽,apes 信息层取 55/60/65/67/70 留出余量,避免与原生面板抢占位置。
|
||||
|
||||
## 二、信息层三 tab(本 fork 的增量)
|
||||
## 二、信息层五 tab(本 fork 的增量)
|
||||
|
||||
三个 tab 全部是**单实例**(`single: true`),且都按 DSH 标准 `TabDescriptor` 注册,
|
||||
五个 tab 全部是**单实例**(`single: true`),且都按 DSH 标准 `TabDescriptor` 注册,
|
||||
因此自动获得原生侧边栏的全部待遇:进入原生 guide、tab 芯片带图标、可开合与分栏。
|
||||
|
||||
| tab | 展示内容 | 数据来源 | 空态 |
|
||||
| --- | --- | --- | --- |
|
||||
| `apes:tasks` | 会话行;当前会话排首位,其次运行中、再次空闲;行上带「当前 / 运行中 / 子智能体」标记;点击切换会话 | `ctx.sessions.list` 快照(`byId` + `current`) | 「当前没有会话」提示 |
|
||||
| `apes:agents` | 根(主代理)卡片 + 其直系子代理卡片;每行显示标签、`one-shot`/`continuable`、运行态圆点 | ① `ctx.sessions.list` 的 `byId`(durable 会话摘要);② 宿主的会话投影 `projectionsBySession[sessionId].values.subagentCatalog`(直系子代理清单) | 「当前会话没有子代理」提示 |
|
||||
| `apes:apps` | 当前会话的工作区(cwd) | `byId[scope.sessionId].cwd`,回退 `scope.cwd` | 明确写出「宿主今日无应用 feed」 |
|
||||
| `apes:messages` | 仅标题与说明(同 `apes:apps` 的占位策略) | —— | 明确写出「宿主今日无消息流 feed」 |
|
||||
| `apes:profile` | 会话标题、运行/空闲、cwd | `byId[scope.sessionId]` | 「无 cwd」提示 |
|
||||
|
||||
**行为契约**:`apes:tasks` / `apes:agents` 声明 `badge`(运行中会话数 / 直系子代理数,为 0 时不渲染角标),
|
||||
其余三个 tab 不发角标;`apes:tasks` 额外声明 `showInactive` 开关,读写落在 `prefs.pluginSettings['apes:tasks']`
|
||||
(缺省显示全部,值非布尔时按缺省处理)。隐藏的 tab 不订阅会话 feed(`visible` 门控),
|
||||
因此后台 tab 不会被会话事件唤醒。
|
||||
|
||||
**实现要点**:两个来源被归一化成同一种行形状 `{ id, label, mode }`,**活动态统一从 `byId` 读**,
|
||||
于是「投影已就绪」与「仅有 durable 摘要」两种情况下渲染一致;这也是上游 0.1.6→0.1.7 会话 feed
|
||||
迁移(`subagentsByParent` → `projectionsBySession`、`list.current` 删除)后的落地方式。
|
||||
|
||||
@@ -33,7 +33,7 @@ src/
|
||||
native/ 原生右侧栏适配(tab 记录、tab 体、tab 芯片标题)
|
||||
builtins/ 内置面板:editor / git / subagent / sidechat / terminal / browser + 预览器
|
||||
sidebar/ 自绘底部工作台与开合控件
|
||||
apes/ 信息层三 tab(本 fork 增量:tabs.tsx / icons.tsx / locales.ts)
|
||||
apes/ 信息层五 tab(本 fork 增量:tabs.tsx / icons.tsx / locales.ts)
|
||||
changes/ diff/ 文件变动与 diff 视图
|
||||
chunks/ 懒加载 chunk:editor / locale / mermaid / terminal
|
||||
locales*.ts i18n(`locales.ts` + 19 个语言覆盖文件)
|
||||
|
||||
+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: {}
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
* node check-plugin-api.mjs [repoDir] [--app "/Applications/DSH Desktop.app"]
|
||||
* 退出码:0 = 通过;1 = 发现问题(可接进 CI / build 前置)
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
|
||||
import { join, relative, dirname } from 'node:path'
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const repoArg = args.find(a => !a.startsWith('--'))
|
||||
|
||||
@@ -113,6 +113,33 @@
|
||||
}
|
||||
|
||||
/* The state dot aligns to the first text line (official catalog detail). */
|
||||
/* The per-row "open beside" affordance (mirrors the host catalog's own row
|
||||
action); revealed on hover/focus so the row stays calm at rest. */
|
||||
.subagentAside {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.subagentRow:hover .subagentAside,
|
||||
.subagentRow:focus-within .subagentAside {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.subagentAside:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.subagentDot {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
+198
-7
@@ -23,7 +23,9 @@ import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent }
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconRefreshOutlineRegular, StateDot,
|
||||
IconChevronRightOutlineRegular,
|
||||
IconRefreshOutlineRegular,
|
||||
StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {
|
||||
Context,
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
rootAncestor,
|
||||
} from './subagent-detect.ts'
|
||||
import { type LastActivity } from '../subagent-activity.ts'
|
||||
import { SUBAGENT_CHAT_KIND, subagentChatAddress } from './native/subagent-chat.ts'
|
||||
import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts'
|
||||
import {
|
||||
collectTreeJobs,
|
||||
@@ -66,6 +69,15 @@ const ARGS_PREVIEW = 60
|
||||
const JOB_POLL_MS = 2000
|
||||
/** How long the kill button stays armed before it needs re-confirming. */
|
||||
const JOB_KILL_ARM_MS = 3000
|
||||
/**
|
||||
* How long the root catalog may stay un-hydrated before the page stops
|
||||
* blocking on "loading…" and degrades to the summary-mirror rows. A catalog
|
||||
* that never hydrates (host seam gap / a failed `agent/disposed` listener
|
||||
* breaking the refresh chain) must not pin the Subagent page on a permanent
|
||||
* loading spinner — the session summaries are already in the mirror, so the
|
||||
* tree degrades to clickable summary rows instead.
|
||||
*/
|
||||
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,
|
||||
@@ -150,6 +162,60 @@ function CatalogLoadingRows(props: {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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
|
||||
}
|
||||
|
||||
/**
|
||||
* The live lines of one RUNNING subagent card: a pure presentation of the
|
||||
* batch `subagents.live` activity. The polling lives in one place (the
|
||||
@@ -220,13 +286,19 @@ interface RowsProps {
|
||||
/** The batch live-preview map (child id → latest activity). */
|
||||
live: Readonly<Record<string, LastActivity>>
|
||||
openChild: (address: SidebarSubagentAddress) => void
|
||||
/**
|
||||
* Open a child's chat as a native right-Sidebar page BESIDE the current
|
||||
* session instead of switching the main pane to it. Absent when the
|
||||
* deployment has no native right Sidebar.
|
||||
*/
|
||||
openChildAside?: (address: SidebarSubagentAddress) => void
|
||||
refresh: (parentSessionId: string) => void
|
||||
}
|
||||
|
||||
/** Render one topology level; branches are always expanded (lazy catalogs). */
|
||||
function CatalogRows({
|
||||
parentSessionId, catalog, catalogs, byId, level, currentSessionId, live,
|
||||
openChild, refresh,
|
||||
openChild, openChildAside, refresh,
|
||||
}: RowsProps) {
|
||||
const emptyLoading = catalog?.state === 'loading' && catalog.entries.length === 0
|
||||
// Side Chat threads are honest catalog citizens (durable descriptor, 'Side: '
|
||||
@@ -320,6 +392,24 @@ function CatalogRows({
|
||||
<SubagentLiveLines live={live[entry.id]} />
|
||||
)}
|
||||
</span>
|
||||
{/* Mirrors the host catalog's own row action: open the child's
|
||||
chat as a right-Sidebar page without leaving this pane. */}
|
||||
{!current && openChildAside !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.subagentAside}
|
||||
title={t('subagentOpenAside')}
|
||||
aria-label={t('subagentOpenAside')}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openChildAside(address)
|
||||
}}
|
||||
onKeyDown={(event) => { event.stopPropagation() }}
|
||||
>
|
||||
<IconChevronRightOutlineRegular />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!knownLeaf && (
|
||||
<div role="group" className={css.subagentChildren} aria-busy={childLoading || undefined}>
|
||||
@@ -341,6 +431,7 @@ function CatalogRows({
|
||||
currentSessionId={currentSessionId}
|
||||
live={live}
|
||||
openChild={openChild}
|
||||
openChildAside={openChildAside}
|
||||
refresh={refresh}
|
||||
/>
|
||||
)}
|
||||
@@ -654,6 +745,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)
|
||||
@@ -702,7 +798,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)
|
||||
}
|
||||
@@ -719,9 +834,42 @@ 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])
|
||||
|
||||
// The native right Sidebar controller — a structural slice only (the service
|
||||
// is absent in plain-web deployments), read lazily like every other host seam.
|
||||
const column = useMemo(
|
||||
() => (ctx as { get?: (name: string) => unknown }).get?.('sidebarRight') as unknown as
|
||||
{ openResource?: (address: string, options?: { kind?: string; preferNewPane?: boolean }) => void } | undefined,
|
||||
[ctx],
|
||||
)
|
||||
|
||||
/**
|
||||
* Open a child's chat as a native right-Sidebar page beside the current
|
||||
* session — the host's own `openChildAside` action, so a user can watch a
|
||||
* child work without surrendering the pane he is in. The in-pane jump stays
|
||||
* `openChild`; this one never switches the main pane.
|
||||
*/
|
||||
const openChildAside = useCallback((address: SidebarSubagentAddress): void => {
|
||||
// The address's `mode` may be an inference (the degraded synthesized
|
||||
// catalog). Prefer the host's REAL descriptor mode for this child — the
|
||||
// `subagentsByParent` entry carries the durable `mode` the session-
|
||||
// controller validates against — and only fall back to the address when
|
||||
// the real catalog never arrived.
|
||||
const entry = catalogs[address.parentSessionId]?.entries.find(
|
||||
(e): e is SidebarSubagentChildEntry => e.kind === 'child' && e.id === address.childSessionId,
|
||||
)
|
||||
const mode = entry?.mode ?? address.mode
|
||||
column?.openResource?.(subagentChatAddress({ ...address, mode }), {
|
||||
kind: SUBAGENT_CHAT_KIND,
|
||||
preferNewPane: true,
|
||||
})
|
||||
}, [column, catalogs])
|
||||
|
||||
const totals = useMemo(
|
||||
() => rootId === undefined
|
||||
? { count: 0, runningCount: 0 }
|
||||
@@ -733,6 +881,27 @@ export function SubagentView(props: {
|
||||
const summaryBackedLoading = rootId !== undefined
|
||||
&& (rootCatalog === undefined || (rootCatalog.state === 'ready' && rootCatalog.entries.length === 0))
|
||||
&& directChildren(byId, rootId).length > 0
|
||||
// Hydrate timeout: once `setSubagentCatalogOpen(rootId, true)` has had a
|
||||
// chance to land and the root catalog is STILL un-hydrated (or an empty
|
||||
// ready), stop blocking on "loading…" and degrade to the summary-mirror
|
||||
// rows. The timer resets when the root switches (a new tree) or when the
|
||||
// catalog actually hydrates (`summaryBackedLoading` goes false), and only
|
||||
// fires while the page is visible (a hidden page is not "loading").
|
||||
const [catalogDegrade, setCatalogDegrade] = useState(false)
|
||||
useEffect(() => {
|
||||
setCatalogDegrade(false)
|
||||
}, [rootId])
|
||||
useEffect(() => {
|
||||
if (!summaryBackedLoading || !active) {
|
||||
setCatalogDegrade(false)
|
||||
return
|
||||
}
|
||||
const timer = window.setTimeout(() => { setCatalogDegrade(true) }, CATALOG_HYDRATE_TIMEOUT_MS)
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}, [summaryBackedLoading, active])
|
||||
// `degraded` is the gate: summary-mirror rows show instead of the
|
||||
// permanent "loading…" spinner once the hydrate timeout has fired.
|
||||
const degraded = summaryBackedLoading && catalogDegrade
|
||||
const readyEmpty = rootCatalog?.state === 'ready'
|
||||
&& rootCatalog.entries.length === 0
|
||||
&& directChildren(byId, rootId ?? '').length === 0
|
||||
@@ -800,7 +969,7 @@ export function SubagentView(props: {
|
||||
<div
|
||||
role="tree"
|
||||
aria-label={t('subagent')}
|
||||
aria-busy={summaryBackedLoading || undefined}
|
||||
aria-busy={(summaryBackedLoading && !degraded) || undefined}
|
||||
>
|
||||
{rootId !== undefined && rootSummary !== undefined && (
|
||||
<div
|
||||
@@ -834,8 +1003,8 @@ export function SubagentView(props: {
|
||||
</div>
|
||||
)}
|
||||
{rootId !== undefined && (
|
||||
<div className={css.subagentChildren} role="group" aria-busy={summaryBackedLoading || undefined}>
|
||||
{summaryBackedLoading && (
|
||||
<div className={css.subagentChildren} role="group" aria-busy={(summaryBackedLoading && !degraded) || undefined}>
|
||||
{summaryBackedLoading && !degraded && (
|
||||
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
|
||||
)}
|
||||
{!summaryBackedLoading && (
|
||||
@@ -848,6 +1017,28 @@ export function SubagentView(props: {
|
||||
currentSessionId={sessionId}
|
||||
live={live}
|
||||
openChild={openChild}
|
||||
openChildAside={openChildAside}
|
||||
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}
|
||||
openChildAside={openChildAside}
|
||||
refresh={refresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -48,3 +48,23 @@ export function IconUserOutline16({ size = 16, className }: { size?: number; cla
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Tasks glyph: a rounded card carrying a check mark. */
|
||||
export function IconTasksOutline16({ size = 16, className }: { size?: number; className?: string }): ReactNode {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="3.6" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M4.7 8.2l2.2 2.2 4.4-4.8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Messages glyph: a speech bubble with a lower-left tail. */
|
||||
export function IconMessagesOutline16({ size = 16, className }: { size?: number; className?: string }): ReactNode {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<path d="M1.5 5.1c0-2 1.6-3.6 3.6-3.6h5.8c2 0 3.6 1.6 3.6 3.6v3.1c0 2-1.6 3.6-3.6 3.6H6.6L2.9 14.5V11.5h-.2c-.7-.6-1.2-1.6-1.2-2.6z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M5.4 7.4h5.2M5.4 4.9h3.1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
/**
|
||||
* apes:* information-layer tabs — the fork's own contribution on top of the
|
||||
* upstream dsh-better-sidebar. Three tabs ship (agents / apps / profile);
|
||||
* `apes:tasks` was removed (C6) because it overlaps the official DSH session
|
||||
* tree in the native sidebar, and `messages` is deferred (no DSH
|
||||
* message-stream API yet — tracked separately).
|
||||
* upstream dsh-better-sidebar. Five tabs ship (tasks / agents / messages /
|
||||
* apps / profile).
|
||||
*
|
||||
* 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),
|
||||
* so the two answer different questions. `messages` is still an honest
|
||||
* placeholder: no DSH message-stream feed exists yet.
|
||||
*
|
||||
* Isolated under `src/client/apes/` so the upstream `builtins/` tree stays
|
||||
* untouched: cherry-picking upstream releases only has to keep the single
|
||||
@@ -20,7 +25,7 @@ import { apesTabs } from './tabs.tsx'
|
||||
|
||||
// Re-exported so index.tsx can register the apes dictionaries + attach the
|
||||
// locale service in one import (keeps the apes module self-contained).
|
||||
export { APES_LOCALE_NS, apesZh, apesEn, attachApesLocale } from './locales.ts'
|
||||
export { APES_LOCALE_NS, apesZh, apesEn, apesJa, attachApesLocale } from './locales.ts'
|
||||
|
||||
/**
|
||||
* Register the apes:* tabs with the sidebar service. Returns a disposer that
|
||||
|
||||
@@ -17,9 +17,19 @@ export const APES_LOCALE_NS = 'apes'
|
||||
/** The zh dictionary (source of truth). */
|
||||
export const apesZh = {
|
||||
// View titles
|
||||
apesTasksTitle: '任务',
|
||||
apesMessagesTitle: '消息',
|
||||
apesAgentsTitle: '智能体',
|
||||
apesAppsTitle: '应用',
|
||||
apesProfileTitle: '画像',
|
||||
// Tasks view
|
||||
apesTasksShowInactive: '显示空闲会话',
|
||||
apesTasksEmpty: '当前没有会话',
|
||||
apesTasksCurrent: '当前',
|
||||
apesTasksRunning: '运行中',
|
||||
apesTasksSubagent: '子智能体',
|
||||
// Messages view
|
||||
apesMessagesNoFeed: '当前 DSH 版本未提供消息流数据源',
|
||||
// Agents view
|
||||
apesAgentsEmpty: '当前会话没有子智能体',
|
||||
apesAgentsRoot: '主智能体',
|
||||
@@ -40,9 +50,17 @@ export const apesZh = {
|
||||
|
||||
/** The en dictionary (checked against zh — every zh key must appear here). */
|
||||
export const apesEn: Record<keyof typeof apesZh, string> = {
|
||||
apesTasksTitle: 'Tasks',
|
||||
apesMessagesTitle: 'Messages',
|
||||
apesAgentsTitle: 'Agents',
|
||||
apesAppsTitle: 'Apps',
|
||||
apesProfileTitle: 'Profile',
|
||||
apesTasksShowInactive: 'Show idle sessions',
|
||||
apesTasksEmpty: 'No sessions right now',
|
||||
apesTasksCurrent: 'current',
|
||||
apesTasksRunning: 'running',
|
||||
apesTasksSubagent: 'subagent',
|
||||
apesMessagesNoFeed: 'This DSH version exposes no message stream feed',
|
||||
apesAgentsEmpty: 'No subagents in this session',
|
||||
apesAgentsRoot: 'Main agent',
|
||||
apesAgentRunning: 'running',
|
||||
@@ -60,7 +78,39 @@ export const apesEn: Record<keyof typeof apesZh, string> = {
|
||||
|
||||
export type ApesCopyKey = keyof typeof apesZh
|
||||
|
||||
/** Resolve the currently active DSH locale ('zh' | 'en' today). */
|
||||
/**
|
||||
* The ja dictionary (AGENTS §5: a new zh key must land in ja in the same
|
||||
* change, otherwise ja readers silently get the en text). Japanese is not a
|
||||
* separate source of truth — it mirrors the en wording, never the zh one.
|
||||
*/
|
||||
export const apesJa: Record<keyof typeof apesZh, string> = {
|
||||
apesTasksTitle: 'タスク',
|
||||
apesMessagesTitle: 'メッセージ',
|
||||
apesAgentsTitle: 'エージェント',
|
||||
apesAppsTitle: 'アプリ',
|
||||
apesProfileTitle: 'プロフィール',
|
||||
apesTasksShowInactive: 'アイドル状態のセッションを表示',
|
||||
apesTasksEmpty: 'セッションがありません',
|
||||
apesTasksCurrent: '現在',
|
||||
apesTasksRunning: '実行中',
|
||||
apesTasksSubagent: 'サブエージェント',
|
||||
apesMessagesNoFeed: 'この DSH バージョンはメッセージストリームの供給元を提供していません',
|
||||
apesAgentsEmpty: 'このセッションにサブエージェントはいません',
|
||||
apesAgentsRoot: 'メインエージェント',
|
||||
apesAgentRunning: '実行中',
|
||||
apesAgentInactive: 'アイドル',
|
||||
apesAgentOneShot: '単発',
|
||||
apesAgentContinuable: '継続可',
|
||||
apesProfileCurrent: '現在のセッション',
|
||||
apesProfileCwd: '作業ディレクトリ',
|
||||
apesProfileNoCwd: '不明',
|
||||
apesProfileRunning: '実行中',
|
||||
apesProfileIdle: 'アイドル',
|
||||
apesAppsNoFeed: 'この DSH バージョンはワークスペースアプリのデータ源を提供していません',
|
||||
apesAppsWorkspace: 'ワークスペース',
|
||||
}
|
||||
|
||||
/** Resolve the currently active DSH locale ('zh' | 'ja' | 'en' today). */
|
||||
export function attachApesLocale(service: { getSnapshot(): { active: string } } | undefined): void {
|
||||
apesLocaleService = service
|
||||
}
|
||||
@@ -69,13 +119,14 @@ let apesLocaleService: { getSnapshot(): { active: string } } | undefined
|
||||
|
||||
/**
|
||||
* Translate an apes key. Resolution order: active-locale chain (zh dict when
|
||||
* the DSH preference is zh, en dict otherwise) → key fallback (shows the key
|
||||
* itself rather than `undefined`). Mirrors the upstream t() minus the
|
||||
* better-locale override (Phase 2).
|
||||
* the DSH preference is zh*, ja dict when it is ja*, en dict otherwise) → key
|
||||
* fallback (shows the key itself rather than `undefined`). Mirrors the
|
||||
* upstream t() minus the better-locale override (Phase 2).
|
||||
*/
|
||||
export function t(key: ApesCopyKey, params?: Record<string, string | number>): string {
|
||||
const active = apesLocaleService?.getSnapshot().active ?? 'zh'
|
||||
const dict = active.toLowerCase().startsWith('zh') ? apesZh : apesEn
|
||||
const lang = active.toLowerCase()
|
||||
const dict = lang.startsWith('zh') ? apesZh : lang.startsWith('ja') ? apesJa : apesEn
|
||||
let text: string | undefined = dict[key]
|
||||
if (text === undefined) text = key
|
||||
if (params !== undefined) {
|
||||
|
||||
+220
-30
@@ -1,17 +1,27 @@
|
||||
/**
|
||||
* apes:* tab views (D2 = data wiring). Each tab reads live data from the
|
||||
* sessions list feed (`ctx.sessions.list`) via `useSyncExternalStore` and
|
||||
* reuses the fork's PURE lineage/job helpers — these are framework-free and
|
||||
* reuses the fork's PURE lineage helpers — these are framework-free and
|
||||
* live outside `builtins/`, so importing them does not couple the apes module
|
||||
* to upstream UI and survives cherry-picks.
|
||||
*
|
||||
* Three information-layer tabs (C6: `apes:tasks` removed — it overlaps the
|
||||
* official DSH session tree in the native sidebar):
|
||||
* - apes:agents → the subagent topology rooted at the main agent of the
|
||||
* current session (rootAncestor + per-parent catalogs).
|
||||
* - apes:apps → 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).
|
||||
* 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.
|
||||
* - apesTasks → every session (running / current / subagent marks) with
|
||||
* an idle filter; a running count badges the tab.
|
||||
* - apesAgents → the subagent topology rooted at the main agent of the
|
||||
* current session (rootAncestor + per-parent catalogs).
|
||||
* - 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.
|
||||
* - 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
|
||||
* stops re-rendering; tab-strip counts ride the cheap `badge` contract instead
|
||||
* of a live subscription.
|
||||
*
|
||||
* Visual unity (C7): colors use the DSW design-token aliases
|
||||
* (`--dsw-alias-*`) so the apes panels match the native DSH sidebar
|
||||
@@ -24,14 +34,19 @@
|
||||
* `subscribe(cb)` is uSES-safe.
|
||||
* - `scope.sessionId` = the currently-open session.
|
||||
*/
|
||||
import { useCallback, useMemo, useState, type CSSProperties } from 'react'
|
||||
import { useCallback, useMemo, type CSSProperties } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { IconAgentOutline16, IconAppsOutline16, IconUserOutline16 } from './icons.tsx'
|
||||
import {
|
||||
IconAgentOutline16,
|
||||
IconAppsOutline16,
|
||||
IconMessagesOutline16,
|
||||
IconTasksOutline16,
|
||||
IconUserOutline16,
|
||||
} from './icons.tsx'
|
||||
import type { TabComponentProps, TabDescriptor } from '../service.ts'
|
||||
import type {
|
||||
SidebarSessionList,
|
||||
SidebarSessionSummary,
|
||||
SidebarSubagentCatalog,
|
||||
SidebarSubagentChildEntry,
|
||||
} from '../../context-types.ts'
|
||||
import { rootAncestor, isSideThreadSummary } from '../subagent-lineage.ts'
|
||||
@@ -66,8 +81,16 @@ const S: Record<string, CSSProperties> = {
|
||||
cardLabel: { fontSize: 13, fontWeight: 500, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: DSW.labelPrimary },
|
||||
cardSecondary: { fontSize: 11, color: DSW.labelSecondary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
||||
badge: { fontSize: 11, color: DSW.labelTertiary, flexShrink: 0 },
|
||||
chip: { fontSize: 10, lineHeight: '15px', padding: '0 6px', borderRadius: 999, border: `1px solid ${DSW.borderL2}`, color: DSW.labelSecondary, flexShrink: 0, whiteSpace: 'nowrap' },
|
||||
taskRow: { appearance: 'none', textAlign: 'left', width: '100%', cursor: 'pointer', font: 'inherit', color: 'inherit' },
|
||||
}
|
||||
|
||||
/** apesTasks descriptor id (the plugin-toggle blob lives under this key). */
|
||||
export const TASKS_TAB_ID = 'apesTasks'
|
||||
|
||||
/** 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). */
|
||||
function Dot(props: { color: string }) {
|
||||
const style: CSSProperties = {
|
||||
@@ -76,15 +99,91 @@ function Dot(props: { color: string }) {
|
||||
return <span aria-hidden style={style} />
|
||||
}
|
||||
|
||||
/** Subscribe to the sessions list feed (uSES-safe). Returns the live snapshot. */
|
||||
function useSessionList(ctx: TabComponentProps['ctx']): SidebarSessionList {
|
||||
const sessions = ctx.sessions
|
||||
/** A tiny status pill (current / running / subagent marks). */
|
||||
function Chip(props: { label: string; color?: string }) {
|
||||
return <span style={props.color === undefined ? S.chip : { ...S.chip, color: props.color, borderColor: props.color }}>{props.label}</span>
|
||||
}
|
||||
|
||||
/** Stable no-op subscription: `visible === false` pauses the feed. */
|
||||
export const NOOP_SUBSCRIBE = (): (() => void) => () => {}
|
||||
|
||||
/**
|
||||
* The subscription a view hands to uSES (C4 pause contract). Exported so the
|
||||
* "hidden tab stops waking on session events" rule is unit-testable without a
|
||||
* DOM: with `visible = false` the feed is never touched.
|
||||
*/
|
||||
export function feedSubscribe(
|
||||
ctx: TabComponentProps['ctx'],
|
||||
visible: boolean,
|
||||
): (callback: () => void) => () => void {
|
||||
if (!visible) return NOOP_SUBSCRIBE
|
||||
return callback => ctx.sessions.list.subscribe(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the sessions list feed (uSES-safe). Returns the live snapshot.
|
||||
* Pass `visible = false` to detach — a hidden tab keeps rendering from the
|
||||
* last snapshot without waking on every session event.
|
||||
*/
|
||||
function useSessionList(ctx: TabComponentProps['ctx'], visible = true): SidebarSessionList {
|
||||
const subscribe = useMemo(() => feedSubscribe(ctx, visible), [ctx, visible])
|
||||
return useSyncExternalStore(
|
||||
useMemo(() => (callback: () => void) => sessions.list.subscribe(callback), [sessions]),
|
||||
useCallback(() => sessions.list.getSnapshot(), [sessions]),
|
||||
subscribe,
|
||||
useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `showInactive` filter state. The declarative settings row writes it into
|
||||
* 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
|
||||
* default and the switch lets the reader trim it down.
|
||||
*/
|
||||
export function tasksShowInactive(store: TabComponentProps['store']): boolean {
|
||||
try {
|
||||
const prefs = store.getPrefs() as unknown as { pluginSettings?: Record<string, Record<string, unknown>> }
|
||||
const blob = prefs.pluginSettings?.[TASKS_TAB_ID]
|
||||
const value = blob?.[TASKS_SHOW_INACTIVE_KEY]
|
||||
return typeof value === 'boolean' ? value : true
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[] {
|
||||
const rows = Object.values(list.byId).filter(s => !isSideThreadSummary(s) && (showInactive || s.running === true))
|
||||
const rank = (s: SidebarSessionSummary): number => (s.id === list.current ? 0 : s.running === true ? 1 : 2)
|
||||
return rows.sort(
|
||||
(a, b) => rank(a) - rank(b) || (a.displayTitle ?? a.id).localeCompare(b.displayTitle ?? b.id),
|
||||
)
|
||||
}
|
||||
|
||||
/** Running sessions (the tasks tab badge). Cheap: one pass, no allocation. */
|
||||
export function runningSessionCount(list: SidebarSessionList): number {
|
||||
let count = 0
|
||||
for (const s of Object.values(list.byId)) {
|
||||
if (s.running === true && !isSideThreadSummary(s)) count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/** Subagents in the given session's tree (the agents tab badge). */
|
||||
export function subagentCount(list: SidebarSessionList, sessionId: string): number {
|
||||
const root = rootAncestor(list.byId, sessionId)
|
||||
let count = 0
|
||||
for (const s of Object.values(list.byId)) {
|
||||
if (s.origin !== 'subagent' || isSideThreadSummary(s)) continue
|
||||
if (rootAncestor(list.byId, s.id) === root) count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/** One level of the subagent topology (rooted at the main agent). */
|
||||
function agentChildren(
|
||||
byId: Readonly<Record<string, SidebarSessionSummary>>,
|
||||
@@ -95,9 +194,69 @@ function agentChildren(
|
||||
)
|
||||
}
|
||||
|
||||
/** apes:agents — the subagent topology of the current session's tree. */
|
||||
/** apesTasks — the flat, whole-workspace session list. */
|
||||
export function ApesTasksView(props: TabComponentProps) {
|
||||
const list = useSessionList(props.ctx, props.visible)
|
||||
const store = props.store
|
||||
const showInactive = useSyncExternalStore(
|
||||
useMemo(() => (callback: () => void) => store.subscribe(callback), [store]),
|
||||
useCallback(() => tasksShowInactive(store), [store]),
|
||||
)
|
||||
const rows = useMemo(() => tasksRows(list, showInactive), [list, showInactive])
|
||||
const open = props.ctx.sessions.open
|
||||
|
||||
return (
|
||||
<div style={S.body}>
|
||||
<h2 style={S.title}>{t('apesTasksTitle')}</h2>
|
||||
{rows.length === 0
|
||||
? <p style={S.hint}>{t('apesTasksEmpty')}</p>
|
||||
: (
|
||||
<div style={S.list}>
|
||||
{rows.map(row => {
|
||||
const running = row.running === true
|
||||
return (
|
||||
<button
|
||||
key={row.id}
|
||||
type="button"
|
||||
aria-current={row.id === list.current ? 'true' : undefined}
|
||||
title={row.displayTitle}
|
||||
onClick={() => { open?.(row.id) }}
|
||||
style={{ ...S.card, ...S.taskRow }}
|
||||
>
|
||||
<div style={S.cardRow}>
|
||||
<Dot color={running ? DSW.success : DSW.labelTertiary} />
|
||||
<span style={S.cardLabel}>{row.displayTitle}</span>
|
||||
{row.id === list.current && <Chip label={t('apesTasksCurrent')} color={DSW.labelPrimary} />}
|
||||
{running && <Chip label={t('apesTasksRunning')} color={DSW.success} />}
|
||||
{row.origin === 'subagent' && <Chip label={t('apesTasksSubagent')} />}
|
||||
</div>
|
||||
{row.cwd !== undefined && (
|
||||
<div style={S.cardRow}>
|
||||
<span style={S.cardSecondary}>{row.cwd}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** apesMessages — DSH exposes no message-stream feed; honest placeholder. */
|
||||
export function ApesMessagesView(_props: TabComponentProps) {
|
||||
return (
|
||||
<div style={S.body}>
|
||||
<h2 style={S.title}>{t('apesMessagesTitle')}</h2>
|
||||
<p style={S.hint}>{t('apesMessagesNoFeed')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** apesAgents — the subagent topology of the current session's tree. */
|
||||
function ApesAgentsView(props: TabComponentProps) {
|
||||
const list = useSessionList(props.ctx)
|
||||
const list = useSessionList(props.ctx, props.visible)
|
||||
const { byId, subagentsByParent } = list
|
||||
const rootId = useMemo(() => rootAncestor(byId, props.scope.sessionId), [byId, props.scope.sessionId])
|
||||
const rootSummary = rootId === undefined ? undefined : byId[rootId]
|
||||
@@ -163,9 +322,9 @@ 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)
|
||||
const list = useSessionList(props.ctx, props.visible)
|
||||
const current = list.current
|
||||
const summary = current === undefined ? undefined : list.byId[current]
|
||||
const cwd = summary?.cwd ?? props.scope.cwd
|
||||
@@ -200,9 +359,9 @@ 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)
|
||||
const list = useSessionList(props.ctx, props.visible)
|
||||
const current = list.current
|
||||
const summary = current === undefined ? undefined : list.byId[current]
|
||||
const cwd = summary?.cwd ?? props.scope.cwd
|
||||
@@ -224,14 +383,45 @@ function ApesAppsView(props: TabComponentProps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The three apes:* tab descriptors (C6: `apes:tasks` removed — overlaps the
|
||||
* official DSH session tree). Order 901-903: agents/apps/profile. Each is
|
||||
* already shaped as `TabDescriptor` (component + single-instance + icon
|
||||
* element + i18n title function) so `registerApesTabs` can hand them straight
|
||||
* to `service.registerTab`. Information-layer tabs are single-instance.
|
||||
* 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
|
||||
* shaped as `TabDescriptor` (component + single-instance + icon element +
|
||||
* i18n title function) so `registerApesTabs` can hand them straight to
|
||||
* `service.registerTab`. Information-layer tabs are single-instance.
|
||||
*/
|
||||
export const apesTabs: TabDescriptor[] = [
|
||||
{ id: 'apes:agents', title: () => t('apesAgentsTitle'), icon: (size: number) => <IconAgentOutline16 size={size} />, order: 60, single: true, component: ApesAgentsView },
|
||||
{ id: 'apes:apps', title: () => t('apesAppsTitle'), icon: (size: number) => <IconAppsOutline16 size={size} />, order: 65, single: true, component: ApesAppsView },
|
||||
{ id: 'apes:profile', title: () => t('apesProfileTitle'), icon: (size: number) => <IconUserOutline16 size={size} />, order: 70, single: true, component: ApesProfileView },
|
||||
{
|
||||
id: TASKS_TAB_ID,
|
||||
title: () => t('apesTasksTitle'),
|
||||
icon: (size: number) => <IconTasksOutline16 size={size} />,
|
||||
order: 55,
|
||||
single: true,
|
||||
component: ApesTasksView,
|
||||
badge: ctx => {
|
||||
const count = runningSessionCount(ctx.sessions.list.getSnapshot())
|
||||
return count > 0 ? count : null
|
||||
},
|
||||
settings: {
|
||||
pluginToggles: [
|
||||
{ key: TASKS_SHOW_INACTIVE_KEY, title: () => t('apesTasksShowInactive'), type: 'switch' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'apesAgents',
|
||||
title: () => t('apesAgentsTitle'),
|
||||
icon: (size: number) => <IconAgentOutline16 size={size} />,
|
||||
order: 60,
|
||||
single: true,
|
||||
component: ApesAgentsView,
|
||||
badge: (ctx, scope) => {
|
||||
const count = subagentCount(ctx.sessions.list.getSnapshot(), scope.sessionId)
|
||||
return count > 0 ? count : null
|
||||
},
|
||||
},
|
||||
{ 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 },
|
||||
]
|
||||
|
||||
@@ -15,7 +15,7 @@ import { allLeaves, createSidebarStore, isAgentTabId } from './state.ts'
|
||||
import { createBetterSidebarService, matchUrlTarget } from './service.ts'
|
||||
import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-loader.ts'
|
||||
import { registerBuiltins } from './builtins/index.ts'
|
||||
import { registerApesTabs, APES_LOCALE_NS, apesZh, apesEn, attachApesLocale } from './apes/index.ts'
|
||||
import { registerApesTabs, APES_LOCALE_NS, apesZh, apesEn, apesJa, attachApesLocale } from './apes/index.ts'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
import { RenderBoundary } from './RenderBoundary.tsx'
|
||||
import { registerTurnTailInterception } from './intercept.tsx'
|
||||
@@ -65,14 +65,16 @@ export function apply(ctx: Context): void {
|
||||
attachLocale(ctx.locale)
|
||||
// The apes module has its own locale namespace (isolated from the upstream
|
||||
// betterSidebar dicts): attach it so apes t() tracks the DSH active-locale
|
||||
// preference, and register its zh/en dicts under `apes`.
|
||||
// preference, and register its zh/ja/en dicts under `apes` (AGENTS §5: a ja
|
||||
// dict must ship with every new zh key, else ja readers fall back to en).
|
||||
attachApesLocale(ctx.locale)
|
||||
ctx.effect(() => {
|
||||
const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh)
|
||||
const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
|
||||
const offApesZh = ctx.locale.register(APES_LOCALE_NS, 'zh', apesZh as Record<string, string>)
|
||||
const offApesJa = ctx.locale.register(APES_LOCALE_NS, 'ja', apesJa as Record<string, string>)
|
||||
const offApesEn = ctx.locale.register(APES_LOCALE_NS, 'en', apesEn as Record<string, string>)
|
||||
return () => { offZh(); offEn(); offApesZh(); offApesEn() }
|
||||
return () => { offZh(); offEn(); offApesZh(); offApesJa(); offApesEn() }
|
||||
}, 'dsh-apes-sidebar: dictionaries')
|
||||
|
||||
// Opt-in third-language support through @huanlin/dsh-plugin-better-locale.
|
||||
|
||||
@@ -327,6 +327,7 @@ export const ar: Record<string, string> = {
|
||||
subagentEmptyDesc: 'الوكلاء الفرعيون المُنتَجون تحت الوكيل الرئيسي سيظهرون هنا',
|
||||
subagentRunning: 'قيد التشغيل',
|
||||
subagentInactive: 'خامل',
|
||||
subagentOpenAside: 'الفتح في الشريط الجانبي',
|
||||
subagentModeOneShot: 'مرة واحدة',
|
||||
subagentModeContinuable: 'قابل للاستئناف',
|
||||
subagentCount: '{count} وكلاء فرعيون',
|
||||
|
||||
@@ -312,6 +312,7 @@ export const de: Record<string, string> = {
|
||||
subagentEmptyDesc: 'Subagenten, die unter dem Hauptagenten erzeugt werden, erscheinen hier',
|
||||
subagentRunning: 'Läuft',
|
||||
subagentInactive: 'Inaktiv',
|
||||
subagentOpenAside: 'In Seitenleiste öffnen',
|
||||
subagentModeOneShot: 'Einmalig',
|
||||
subagentModeContinuable: 'Fortsetzbar',
|
||||
subagentCount: '{count} Subagenten',
|
||||
|
||||
@@ -319,6 +319,7 @@ export const fr: Record<string, string> = {
|
||||
subagentEmptyDesc: 'Les sous-agents dérivés de l’agent principal actuel s’afficheront ici',
|
||||
subagentRunning: 'En cours',
|
||||
subagentInactive: 'Inactif',
|
||||
subagentOpenAside: 'Ouvrir dans le volet',
|
||||
subagentModeOneShot: 'À usage unique',
|
||||
subagentModeContinuable: 'Reprenable',
|
||||
subagentCount: '{count} sous-agent(s)',
|
||||
|
||||
@@ -326,6 +326,7 @@ export const hi: Record<string, string> = {
|
||||
subagentEmptyDesc: 'मुख्य एजेंट के अंतर्गत बने सबएजेंट यहाँ दिखेंगे',
|
||||
subagentRunning: 'चल रहा',
|
||||
subagentInactive: 'निष्क्रिय',
|
||||
subagentOpenAside: 'साइडबार में खोलें',
|
||||
subagentModeOneShot: 'एक-बार',
|
||||
subagentModeContinuable: 'जारी रखने योग्य',
|
||||
subagentCount: '{count} सबएजेंट',
|
||||
|
||||
@@ -324,6 +324,7 @@ export const id: Record<string, string> = {
|
||||
subagentEmptyDesc: 'Subagen yang dibangkitkan di bawah agen utama akan muncul di sini',
|
||||
subagentRunning: 'Berjalan',
|
||||
subagentInactive: 'Menganggur',
|
||||
subagentOpenAside: 'Buka di bilah samping',
|
||||
subagentModeOneShot: 'Sekali pakai',
|
||||
subagentModeContinuable: 'Dapat dilanjutkan',
|
||||
subagentCount: '{count} subagen',
|
||||
|
||||
@@ -317,6 +317,7 @@ export const it: Record<string, string> = {
|
||||
subagentEmptyDesc: 'I sottoagenti generati dall’agente principale compariranno qui',
|
||||
subagentRunning: 'In esecuzione',
|
||||
subagentInactive: 'Inattivo',
|
||||
subagentOpenAside: 'Apri nella barra laterale',
|
||||
subagentModeOneShot: 'Monouso',
|
||||
subagentModeContinuable: 'Proseguibile',
|
||||
subagentCount: '{count} sottoagenti',
|
||||
|
||||
@@ -326,6 +326,7 @@ export const ja: Record<string, string> = {
|
||||
subagentEmptyDesc: 'メインエージェントが派生したサブエージェントはここに表示されます',
|
||||
subagentRunning: '実行中',
|
||||
subagentInactive: 'アイドル',
|
||||
subagentOpenAside: 'サイドバーで開く',
|
||||
subagentModeOneShot: 'ワンショット',
|
||||
subagentModeContinuable: '継続可',
|
||||
subagentCount: '{count} 個のサブエージェント',
|
||||
|
||||
@@ -318,6 +318,7 @@ export const ko: Record<string, string> = {
|
||||
subagentEmptyDesc: '현재 주 에이전트에서 파생된 서브 에이전트가 여기에 표시됩니다',
|
||||
subagentRunning: '실행 중',
|
||||
subagentInactive: '대기 중',
|
||||
subagentOpenAside: '사이드바에서 열기',
|
||||
subagentModeOneShot: '일회성',
|
||||
subagentModeContinuable: '이어서 가능',
|
||||
subagentCount: '서브 에이전트 {count}개',
|
||||
|
||||
@@ -324,6 +324,7 @@ export const nl: Record<string, string> = {
|
||||
subagentEmptyDesc: 'Subagents voortgebracht door de hoofdagent verschijnen hier',
|
||||
subagentRunning: 'Actief',
|
||||
subagentInactive: 'Inactief',
|
||||
subagentOpenAside: 'Openen in zijbalk',
|
||||
subagentModeOneShot: 'One-shot',
|
||||
subagentModeContinuable: 'Vervolgbaar',
|
||||
subagentCount: '{count} subagents',
|
||||
|
||||
@@ -328,6 +328,7 @@ export const pl: Record<string, string> = {
|
||||
subagentEmptyDesc: 'Podagenci powołani przez agenta głównego pojawią się tutaj',
|
||||
subagentRunning: 'Działa',
|
||||
subagentInactive: 'Bezczynny',
|
||||
subagentOpenAside: 'Otwórz na pasku bocznym',
|
||||
subagentModeOneShot: 'Jednorazowy',
|
||||
subagentModeContinuable: 'Kontynuowalny',
|
||||
subagentCount: '{count} podagentów',
|
||||
|
||||
@@ -309,6 +309,7 @@ export const pt: Record<string, string> = {
|
||||
subagentEmptyDesc: 'Subagentes gerados sob o agente principal aparecerão aqui',
|
||||
subagentRunning: 'Executando',
|
||||
subagentInactive: 'Inativo',
|
||||
subagentOpenAside: 'Abrir na barra lateral',
|
||||
subagentModeOneShot: 'De uso único',
|
||||
subagentModeContinuable: 'Continuável',
|
||||
subagentCount: '{count} subagentes',
|
||||
|
||||
@@ -324,6 +324,7 @@ export const ru: Record<string, string> = {
|
||||
subagentEmptyDesc: 'Субагенты, порождённые главным агентом, появятся здесь',
|
||||
subagentRunning: 'Выполняется',
|
||||
subagentInactive: 'Простаивает',
|
||||
subagentOpenAside: 'Открыть на боковой панели',
|
||||
subagentModeOneShot: 'Одноразовый',
|
||||
subagentModeContinuable: 'Продолжаемый',
|
||||
subagentCount: '{count} субагентов',
|
||||
|
||||
@@ -309,6 +309,7 @@ export const sv: Record<string, string> = {
|
||||
subagentEmptyDesc: 'Subagenter skapade under huvudagenten visas här',
|
||||
subagentRunning: 'Kör',
|
||||
subagentInactive: 'Inaktiv',
|
||||
subagentOpenAside: 'Öppna i sidofältet',
|
||||
subagentModeOneShot: 'Engångs',
|
||||
subagentModeContinuable: 'Fortsättbar',
|
||||
subagentCount: '{count} subagenter',
|
||||
|
||||
@@ -326,6 +326,7 @@ export const th: Record<string, string> = {
|
||||
subagentEmptyDesc: 'ตัวแทนย่อยที่สร้างภายใต้ตัวแทนหลักจะปรากฏที่นี่',
|
||||
subagentRunning: 'กำลังทำงาน',
|
||||
subagentInactive: 'ไม่ได้ทำงาน',
|
||||
subagentOpenAside: 'เปิดในแถบด้านข้าง',
|
||||
subagentModeOneShot: 'ครั้งเดียว',
|
||||
subagentModeContinuable: 'ทำต่อได้',
|
||||
subagentCount: '{count} ตัวแทนย่อย',
|
||||
|
||||
@@ -326,6 +326,7 @@ export const tr: Record<string, string> = {
|
||||
subagentEmptyDesc: 'Ana aracının altında doğan alt aracılar burada görünür',
|
||||
subagentRunning: 'Çalışıyor',
|
||||
subagentInactive: 'Etkin değil',
|
||||
subagentOpenAside: 'Kenar çubuğunda aç',
|
||||
subagentModeOneShot: 'Tek seferlik',
|
||||
subagentModeContinuable: 'Sürdürülebilir',
|
||||
subagentCount: '{count} alt aracı',
|
||||
|
||||
@@ -326,6 +326,7 @@ export const vi: Record<string, string> = {
|
||||
subagentEmptyDesc: 'Tác nhân con do tác nhân chính tạo ra sẽ hiển thị ở đây',
|
||||
subagentRunning: 'Đang chạy',
|
||||
subagentInactive: 'Nghỉ',
|
||||
subagentOpenAside: 'Mở trong thanh bên',
|
||||
subagentModeOneShot: 'Một lần',
|
||||
subagentModeContinuable: 'Có thể tiếp tục',
|
||||
subagentCount: '{count} tác nhân con',
|
||||
|
||||
@@ -341,6 +341,7 @@ export const zhHK: Record<string, string> = {
|
||||
subagentEmptyDesc: '目前主代理派生的子代理將顯示在這裡',
|
||||
subagentRunning: '執行中',
|
||||
subagentInactive: '閒置',
|
||||
subagentOpenAside: 'Open in sidebar',
|
||||
subagentModeOneShot: '一次性',
|
||||
subagentModeContinuable: '可續接',
|
||||
subagentCount: '{count} 個子代理',
|
||||
|
||||
@@ -341,6 +341,7 @@ export const zhMO: Record<string, string> = {
|
||||
subagentEmptyDesc: '目前主代理派生的子代理將顯示在這裡',
|
||||
subagentRunning: '執行中',
|
||||
subagentInactive: '閒置',
|
||||
subagentOpenAside: 'Open in sidebar',
|
||||
subagentModeOneShot: '一次性',
|
||||
subagentModeContinuable: '可續接',
|
||||
subagentCount: '{count} 個子代理',
|
||||
|
||||
@@ -341,6 +341,7 @@ export const zhTW: Record<string, string> = {
|
||||
subagentEmptyDesc: '目前主代理派生的子代理將顯示在這裡',
|
||||
subagentRunning: '執行中',
|
||||
subagentInactive: '閒置',
|
||||
subagentOpenAside: '在側欄中開啟',
|
||||
subagentModeOneShot: '一次性',
|
||||
subagentModeContinuable: '可續接',
|
||||
subagentCount: '{count} 個子代理',
|
||||
|
||||
@@ -338,6 +338,7 @@ export const zh = {
|
||||
subagentEmptyDesc: '当前主代理派生的子代理将显示在这里',
|
||||
subagentRunning: '运行中',
|
||||
subagentInactive: '空闲',
|
||||
subagentOpenAside: '在侧栏中打开',
|
||||
subagentModeOneShot: '一次性',
|
||||
subagentModeContinuable: '可续接',
|
||||
subagentCount: '{count} 个子代理',
|
||||
@@ -787,6 +788,7 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
subagentEmptyDesc: 'Subagents spawned under the main agent will appear here',
|
||||
subagentRunning: 'Running',
|
||||
subagentInactive: 'Inactive',
|
||||
subagentOpenAside: 'Open in sidebar',
|
||||
subagentModeOneShot: 'One-shot',
|
||||
subagentModeContinuable: 'Continuable',
|
||||
subagentCount: '{count} subagents',
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Resource-address codec for an embedded subagent Session chat.
|
||||
*
|
||||
* Mirrors `@deepseek-ai/dsh-client-ui-subagent`'s own `subagentChatAddress`
|
||||
* (the `dsh-resource://subagentchat/session/<childId>?parent&mode` form the
|
||||
* native right Sidebar roots its `subagentchat` pages under). Copied rather
|
||||
* than imported: the package exports no helpers and the plugin must not depend
|
||||
* on its internals. Kept dependency-free so it is unit-testable in node.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
import type { SidebarSubagentAddress } from '../../context-types.ts'
|
||||
|
||||
/** The only modes the host's session-controller will accept in an address. */
|
||||
const KNOWN_MODES = new Set(['one-shot', 'continuable'])
|
||||
|
||||
/** Resource-address prefix for an embedded Session chat. */
|
||||
export const SUBAGENT_CHAT_ADDRESS = 'dsh-resource://subagentchat/session/'
|
||||
|
||||
/** The native right Sidebar page kind an embedded Session chat opens as. */
|
||||
export const SUBAGENT_CHAT_KIND = 'subagentchat'
|
||||
|
||||
/**
|
||||
* Address one subagent Session together with the routing facts needed to
|
||||
* restore it.
|
||||
* @param address - durable direct-parent subagent address.
|
||||
* @returns canonical Sidebar resource address.
|
||||
*/
|
||||
export function subagentChatAddress(address: SidebarSubagentAddress): string {
|
||||
// `mode` is a GATE, not a label: the host's session-controller rejects an
|
||||
// address whose `mode` differs from the child's durable descriptor mode
|
||||
// (`subagent/unauthorized: subagent mode does not match the supplied
|
||||
// address`). The row-level `mode` can be a best-effort inference (the
|
||||
// degraded summary-synthesized catalog guesses from `running`), so a wrong
|
||||
// guess used to 401 the very open it was meant to trigger. A valid mode is
|
||||
// passed through verbatim; anything else is OMITTED, which the controller
|
||||
// reads as `mode: "unknown"` and therefore skips the comparison entirely —
|
||||
// the safest form when the caller does not actually know the mode.
|
||||
const query = new URLSearchParams({ parent: address.parentSessionId })
|
||||
if (KNOWN_MODES.has(address.mode)) query.set('mode', address.mode)
|
||||
return `${SUBAGENT_CHAT_ADDRESS}${encodeURIComponent(address.childSessionId)}?${query.toString()}`
|
||||
}
|
||||
@@ -26,14 +26,27 @@ type Pending =
|
||||
| { kind: 'tab'; sessionId: string; tabKind: string; params: NativeTabParams; revealIfOpened: boolean }
|
||||
| { kind: 'resource'; sessionId: string; address: string; line: number | undefined; revealIfOpened: boolean }
|
||||
|
||||
/**
|
||||
* Placement + navigation options one native open accepts. `kind` is the page
|
||||
* kind a resource opens as (e.g. `subagentchat`) and `preferNewPane` forces a
|
||||
* fresh pane instead of reusing a matching one — both are what the host's own
|
||||
* subagent UI passes when it opens a child chat beside the current session.
|
||||
*/
|
||||
interface NativeOpenOptions {
|
||||
params?: unknown
|
||||
revealIfOpened?: boolean
|
||||
kind?: string
|
||||
preferNewPane?: boolean
|
||||
}
|
||||
|
||||
/** The controller face this module uses (a structural slice of `ISidebarRight`). */
|
||||
interface NativeController {
|
||||
openTab(kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
|
||||
openResource(address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
|
||||
openTab(kind: string, options?: NativeOpenOptions): void
|
||||
openResource(address: string, options?: NativeOpenOptions): void
|
||||
close(tabId: string): void
|
||||
/** Not part of `ISidebarRight`: the concrete controller's per-session writes. */
|
||||
openTabIn?(sessionId: string, kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
|
||||
openResourceIn?(sessionId: string, address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
|
||||
openTabIn?(sessionId: string, kind: string, options?: NativeOpenOptions): void
|
||||
openResourceIn?(sessionId: string, address: string, options?: NativeOpenOptions): void
|
||||
closeIn?(sessionId: string, tabId: string): void
|
||||
}
|
||||
|
||||
|
||||
+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. */
|
||||
|
||||
+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>
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import { WebSocket, WebSocketServer } from 'ws'
|
||||
import type { Context, SidebarHttpRequest, SidebarSessionEvent } from './context-types.ts'
|
||||
import {
|
||||
Config,
|
||||
PrefsSchema,
|
||||
resolveSidebarConfig,
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
SIDEBAR_PREFS_NS,
|
||||
|
||||
+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 {
|
||||
|
||||
+239
-26
@@ -1,25 +1,44 @@
|
||||
/**
|
||||
* apes:* tab registration tests (Phase 1/1b, C6: 3 tabs). The fork registers
|
||||
* its three information-layer tabs (agents / apps / profile) through the same
|
||||
* BetterSidebarService the upstream builtins use (dogfood). `apes:tasks` was
|
||||
* removed in C6 — it overlaps the official DSH session tree in the native
|
||||
* sidebar. The spec pins the registration contract (id / single / order /
|
||||
* component presence) and the isolated i18n contract (the apes namespace
|
||||
* resolves zh vs en through the attached locale service, and every apesZh
|
||||
* key has an apesEn entry).
|
||||
* 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).
|
||||
* `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.
|
||||
*
|
||||
* The spec pins: the registration contract (id / single / order / component /
|
||||
* icon), the two Phase-2 increments (running-count + subagent-count `badge`
|
||||
* functions, and the C4 `visible` pause on the sessions-list feed), the
|
||||
* `showInactive` plugin-toggle channel, the PURE row helpers, and the isolated
|
||||
* i18n contract (zh / ja / en parity + active-locale resolution).
|
||||
*
|
||||
* Runs in the node vitest environment like builtins.spec.ts — no DOM, no
|
||||
* real DSH host (a live cordis scratch mount is Phase 2+).
|
||||
* real DSH host (a live cordis scratch mount is covered by the mount e2e).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
// Browser globals before the client graph loads (mirror of builtins.spec.ts).
|
||||
import './browser-globals.ts'
|
||||
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
import type { SidebarSessionList, SidebarSessionSummary } from '../src/context-types.ts'
|
||||
import { createBetterSidebarService } from '../src/client/service.ts'
|
||||
import type { SessionScope, SidebarState, TabComponentProps } from '../src/client/service.ts'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
import { registerApesTabs } from '../src/client/apes/index.ts'
|
||||
import { apesZh, apesEn, attachApesLocale, t, type ApesCopyKey } from '../src/client/apes/locales.ts'
|
||||
import { apesZh, apesEn, apesJa, attachApesLocale, t, type ApesCopyKey } from '../src/client/apes/locales.ts'
|
||||
import {
|
||||
NOOP_SUBSCRIBE,
|
||||
TASKS_SHOW_INACTIVE_KEY,
|
||||
TASKS_TAB_ID,
|
||||
apesTabs,
|
||||
feedSubscribe,
|
||||
runningSessionCount,
|
||||
subagentCount,
|
||||
tasksRows,
|
||||
tasksShowInactive,
|
||||
} from '../src/client/apes/tabs.tsx'
|
||||
|
||||
const APES_TAB_IDS = ['apesAgents', 'apesApps', 'apesMessages', 'apesProfile', 'apesTasks']
|
||||
|
||||
function setup(): {
|
||||
service: ReturnType<typeof createBetterSidebarService>
|
||||
@@ -32,13 +51,47 @@ function setup(): {
|
||||
return { service, store, dispose }
|
||||
}
|
||||
|
||||
/** A durable session row fixture. */
|
||||
function row(id: string, over: Partial<SidebarSessionSummary> = {}): SidebarSessionSummary {
|
||||
return { id, displayTitle: id, ...over }
|
||||
}
|
||||
|
||||
/** A session-list snapshot fixture. */
|
||||
function snap(current: string | undefined, rows: readonly SidebarSessionSummary[]): SidebarSessionList {
|
||||
return { current, byId: Object.fromEntries(rows.map(r => [r.id, r])) }
|
||||
}
|
||||
|
||||
/** A minimal ctx carrying only the sessions-list feed the apes views read. */
|
||||
function fakeCtx(list: SidebarSessionList, onSubscribe?: () => void): Context {
|
||||
return {
|
||||
sessions: {
|
||||
list: {
|
||||
getSnapshot: () => list,
|
||||
subscribe: () => {
|
||||
onSubscribe?.()
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
/** Invoke a descriptor's badge with a fixture snapshot. */
|
||||
function badgeOf(id: string, list: SidebarSessionList, sessionId = 'main'): string | number | null | undefined {
|
||||
const descriptor = apesTabs.find(d => d.id === id)
|
||||
expect(descriptor, `${id} descriptor`).toBeDefined()
|
||||
return descriptor?.badge?.(
|
||||
fakeCtx(list),
|
||||
{ sessionId } as SessionScope,
|
||||
{} as SidebarState,
|
||||
)
|
||||
}
|
||||
|
||||
describe('apes:* tab registrations', () => {
|
||||
it('registers the 3 apes tabs with the apes: namespace', () => {
|
||||
it('registers the 5 apes tabs with the apes: namespace', () => {
|
||||
const { service, dispose } = setup()
|
||||
try {
|
||||
expect(service.getTabs().map(t => t.id).sort()).toEqual(
|
||||
['apes:agents', 'apes:apps', 'apes:profile'],
|
||||
)
|
||||
expect(service.getTabs().map(t => t.id).sort()).toEqual(APES_TAB_IDS)
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
@@ -47,35 +100,40 @@ describe('apes:* tab registrations', () => {
|
||||
it('every apes tab is single-instance, visible, and component-carrying', () => {
|
||||
const { service, dispose } = setup()
|
||||
try {
|
||||
for (const id of ['apes:agents', 'apes:apps', 'apes:profile']) {
|
||||
for (const id of APES_TAB_IDS) {
|
||||
const tab = service.getTab(id)
|
||||
expect(tab, `${id} registered`).toBeDefined()
|
||||
expect(tab?.single, `${id} single`).toBe(true)
|
||||
expect(tab?.hidden, `${id} hidden`).not.toBe(true)
|
||||
expect(tab?.component, `${id} component`).toBeDefined()
|
||||
expect(tab?.icon, `${id} icon`).toBeDefined()
|
||||
expect(typeof tab?.title === 'function' ? tab.title() : tab?.title, `${id} title`).toBeTruthy()
|
||||
}
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('apes tabs sort after the upstream builtins (order 40+)', () => {
|
||||
it('apes tabs sort after the upstream builtins (order 40+) in reading order', () => {
|
||||
const { service, dispose } = setup()
|
||||
try {
|
||||
const orders = {
|
||||
agents: service.getTab('apes:agents')?.order,
|
||||
apps: service.getTab('apes:apps')?.order,
|
||||
profile: service.getTab('apes:profile')?.order,
|
||||
tasks: service.getTab(TASKS_TAB_ID)?.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.
|
||||
expect(orders.agents).toBeGreaterThanOrEqual(40)
|
||||
expect(orders.apps).toBeGreaterThanOrEqual(40)
|
||||
expect(orders.profile).toBeGreaterThanOrEqual(40)
|
||||
// Strictly ordered: agents < apps < profile.
|
||||
for (const [id, order] of Object.entries(orders)) {
|
||||
expect(order, `${id} order`).toBeGreaterThanOrEqual(40)
|
||||
}
|
||||
// Strictly ordered: tasks < agents < apps < messages < profile.
|
||||
expect(orders.tasks!).toBeLessThan(orders.agents!)
|
||||
expect(orders.agents!).toBeLessThan(orders.apps!)
|
||||
expect(orders.apps!).toBeLessThan(orders.profile!)
|
||||
expect(orders.apps!).toBeLessThan(orders.messages!)
|
||||
expect(orders.messages!).toBeLessThan(orders.profile!)
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
@@ -85,14 +143,146 @@ describe('apes:* tab registrations', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const dispose = registerApesTabs({} as Context, service)
|
||||
expect(service.getTabs()).toHaveLength(3)
|
||||
expect(service.getTabs()).toHaveLength(5)
|
||||
dispose()
|
||||
expect(service.getTabs()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
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 ?? []
|
||||
expect(toggles).toHaveLength(1)
|
||||
const toggle = toggles[0]
|
||||
expect(toggle?.key).toBe(TASKS_SHOW_INACTIVE_KEY)
|
||||
expect(toggle?.key).toBe('showInactive')
|
||||
expect(toggle?.type).toBe('switch')
|
||||
expect(typeof toggle?.title === 'function' ? toggle.title() : toggle?.title).toBeTruthy()
|
||||
})
|
||||
|
||||
it('defaults to showing every session when no pref is stored', () => {
|
||||
const store = { getPrefs: () => ({}) } as unknown as TabComponentProps['store']
|
||||
expect(tasksShowInactive(store)).toBe(true)
|
||||
})
|
||||
|
||||
it('reads the boolean from pluginSettings[apesTasks]', () => {
|
||||
const store = {
|
||||
getPrefs: () => ({ pluginSettings: { [TASKS_TAB_ID]: { [TASKS_SHOW_INACTIVE_KEY]: false } } }),
|
||||
} as unknown as TabComponentProps['store']
|
||||
expect(tasksShowInactive(store)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores non-boolean values and failing stores (fail soft)', () => {
|
||||
const odd = {
|
||||
getPrefs: () => ({ pluginSettings: { [TASKS_TAB_ID]: { [TASKS_SHOW_INACTIVE_KEY]: 'nope' } } }),
|
||||
} as unknown as TabComponentProps['store']
|
||||
expect(tasksShowInactive(odd)).toBe(true)
|
||||
const broken = { getPrefs: () => { throw new Error('no prefs') } } as unknown as TabComponentProps['store']
|
||||
expect(tasksShowInactive(broken)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('apesTasks row selection (PURE)', () => {
|
||||
it('ranks current first, then running, then idle', () => {
|
||||
const list = snap('idle-b', [
|
||||
row('idle-a'),
|
||||
row('run-2', { running: true }),
|
||||
row('idle-b'),
|
||||
row('run-1', { running: true }),
|
||||
])
|
||||
expect(tasksRows(list, true).map(r => r.id)).toEqual(['idle-b', 'run-1', 'run-2', 'idle-a'])
|
||||
})
|
||||
|
||||
it('hides idle sessions when showInactive is false', () => {
|
||||
const list = snap('main', [row('main'), row('work', { running: true }), row('sleeping')])
|
||||
// C-2 fidelity: v2.0.0 filters on `running` alone, so an idle *current*
|
||||
// session is dropped too — the toggle means "idle sessions disappear",
|
||||
// not "always keep the one I am looking at".
|
||||
expect(tasksRows(list, false).map(r => r.id)).toEqual(['work'])
|
||||
expect(tasksRows(list, true).map(r => r.id)).toEqual(['main', 'work', 'sleeping'])
|
||||
})
|
||||
|
||||
it('never lists side-chat threads (they are not topology)', () => {
|
||||
const list = snap('main', [row('main'), row('side', { origin: 'subagent', displayTitle: 'Side: main' })])
|
||||
expect(tasksRows(list, true).map(r => r.id)).toEqual(['main'])
|
||||
})
|
||||
|
||||
it('counts running sessions for the badge', () => {
|
||||
const list = snap('main', [
|
||||
row('main', { running: true }),
|
||||
row('child', { running: true, origin: 'subagent', parentId: 'main' }),
|
||||
row('idle'),
|
||||
row('side', { origin: 'subagent', displayTitle: 'Side: main' }),
|
||||
])
|
||||
expect(runningSessionCount(list)).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('apesAgents badge + topology count', () => {
|
||||
it('counts the subagents of the current session tree only', () => {
|
||||
const list = snap('main', [
|
||||
row('main'),
|
||||
row('c1', { origin: 'subagent', parentId: 'main' }),
|
||||
row('c2', { origin: 'subagent', parentId: 'c1' }),
|
||||
row('other'),
|
||||
row('o1', { origin: 'subagent', parentId: 'other' }),
|
||||
])
|
||||
expect(subagentCount(list, 'main')).toBe(2)
|
||||
expect(subagentCount(list, 'c1')).toBe(2)
|
||||
expect(subagentCount(list, 'other')).toBe(1)
|
||||
expect(subagentCount(list, 'unknown')).toBe(0)
|
||||
})
|
||||
|
||||
it('excludes side-chat threads from the count', () => {
|
||||
const list = snap('main', [
|
||||
row('main'),
|
||||
row('side', { origin: 'subagent', parentId: 'main', displayTitle: 'Side: main' }),
|
||||
row('c1', { origin: 'subagent', parentId: 'main' }),
|
||||
])
|
||||
expect(subagentCount(list, 'main')).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('apes badge contract', () => {
|
||||
it('badges the tasks tab with the running count, null when idle', () => {
|
||||
expect(badgeOf(TASKS_TAB_ID, snap('main', [row('a', { running: true }), row('b')]))).toBe(1)
|
||||
expect(badgeOf(TASKS_TAB_ID, snap('main', [row('a'), row('b')]))).toBeNull()
|
||||
})
|
||||
|
||||
it('badges the agents tab with the subagent count, null when childless', () => {
|
||||
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 ['apesApps', 'apesMessages', 'apesProfile']) {
|
||||
expect(apesTabs.find(d => d.id === id)?.badge, `${id} badge`).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('apes C4 visible pause', () => {
|
||||
it('does not touch the sessions feed while the tab is hidden', () => {
|
||||
let subscribed = 0
|
||||
const ctx = fakeCtx(snap('main', [row('main')]), () => { subscribed += 1 })
|
||||
feedSubscribe(ctx, false)
|
||||
expect(subscribed).toBe(0)
|
||||
const stop = feedSubscribe(ctx, true) (() => {})
|
||||
expect(subscribed).toBe(1)
|
||||
expect(typeof stop).toBe('function')
|
||||
stop()
|
||||
})
|
||||
|
||||
it('exposes a stable no-op subscription', () => {
|
||||
const stop = NOOP_SUBSCRIBE()
|
||||
expect(typeof stop).toBe('function')
|
||||
expect(() => stop()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('apes i18n contract', () => {
|
||||
it('every apesZh key has an apesEn entry (no missing translations)', () => {
|
||||
it('every apesZh key has an en entry (no missing translations)', () => {
|
||||
const zhKeys = Object.keys(apesZh) as ApesCopyKey[]
|
||||
for (const key of zhKeys) {
|
||||
expect(apesEn[key], `en entry for ${key}`).toBeDefined()
|
||||
@@ -101,6 +291,15 @@ describe('apes i18n contract', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('every apesZh key has a ja entry (AGENTS §5)', () => {
|
||||
const zhKeys = Object.keys(apesZh) as ApesCopyKey[]
|
||||
for (const key of zhKeys) {
|
||||
expect(apesJa[key], `ja entry for ${key}`).toBeDefined()
|
||||
expect(typeof apesJa[key]).toBe('string')
|
||||
expect((apesJa[key] ?? '').length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves zh when the DSH preference is zh', () => {
|
||||
attachApesLocale({ getSnapshot: () => ({ active: 'zh-CN' }) })
|
||||
expect(t('apesAgentsTitle')).toBe('智能体')
|
||||
@@ -108,11 +307,25 @@ describe('apes i18n contract', () => {
|
||||
expect(t('apesProfileTitle')).toBe('画像')
|
||||
})
|
||||
|
||||
it('resolves the new tasks/messages titles in zh', () => {
|
||||
attachApesLocale({ getSnapshot: () => ({ active: 'zh' }) })
|
||||
expect(t('apesTasksTitle')).toBe('任务')
|
||||
expect(t('apesMessagesTitle')).toBe('消息')
|
||||
})
|
||||
|
||||
it('resolves en when the DSH preference is en', () => {
|
||||
attachApesLocale({ getSnapshot: () => ({ active: 'en' }) })
|
||||
expect(t('apesAgentsTitle')).toBe('Agents')
|
||||
expect(t('apesAppsTitle')).toBe('Apps')
|
||||
expect(t('apesProfileTitle')).toBe('Profile')
|
||||
expect(t('apesTasksTitle')).toBe('Tasks')
|
||||
})
|
||||
|
||||
it('resolves ja when the DSH preference is ja', () => {
|
||||
attachApesLocale({ getSnapshot: () => ({ active: 'ja-JP' }) })
|
||||
expect(t('apesAgentsTitle')).toBe('エージェント')
|
||||
expect(t('apesTasksTitle')).toBe('タスク')
|
||||
expect(t('apesMessagesTitle')).toBe('メッセージ')
|
||||
})
|
||||
|
||||
it('falls back to zh when no locale service is attached', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+16
-14
@@ -11,8 +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 { PrefsSchema } from '../src/config.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'
|
||||
@@ -840,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 })
|
||||
@@ -892,7 +891,10 @@ describe('side card settings routes', () => {
|
||||
effect: (fn: () => void | (() => void)) => { fn() },
|
||||
inject: (deps: string[], callback: (sctx: { settings: unknown }) => void) => {
|
||||
if (deps.includes('settings') && settings !== undefined) {
|
||||
const s = settings as { update: Function; describe: Function }
|
||||
const s = settings as {
|
||||
update: (ns: string, ...rest: unknown[]) => unknown
|
||||
describe: (ns: string, ...rest: unknown[]) => unknown
|
||||
}
|
||||
// Wrap update to emit `settings/document-updated` after each write,
|
||||
// mirroring the real SettingsForms service behavior.
|
||||
const origUpdate = s.update.bind(s)
|
||||
@@ -977,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',
|
||||
},
|
||||
@@ -1182,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,
|
||||
@@ -1213,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)
|
||||
})
|
||||
@@ -1239,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,
|
||||
@@ -1270,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)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The `subagentchat` resource address must stay byte-compatible with the host's
|
||||
* own codec: `@deepseek-ai/dsh-client-ui-subagent` parses what we emit, so a
|
||||
* different query order or an unescaped child id would silently open the wrong
|
||||
* page (or none).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
SUBAGENT_CHAT_ADDRESS,
|
||||
SUBAGENT_CHAT_KIND,
|
||||
subagentChatAddress,
|
||||
} from '../src/client/native/subagent-chat.ts'
|
||||
|
||||
describe('subagentChatAddress', () => {
|
||||
it("emits the canonical form with parent and mode", () => {
|
||||
expect(subagentChatAddress({
|
||||
parentSessionId: 'parent-1',
|
||||
childSessionId: 'child-2',
|
||||
mode: 'one-shot',
|
||||
})).toBe('dsh-resource://subagentchat/session/child-2?parent=parent-1&mode=one-shot')
|
||||
})
|
||||
|
||||
it('keeps a continuable mode verbatim', () => {
|
||||
expect(subagentChatAddress({
|
||||
parentSessionId: 'p',
|
||||
childSessionId: 'c',
|
||||
mode: 'continuable',
|
||||
})).toContain('mode=continuable')
|
||||
})
|
||||
|
||||
it('percent-encodes a child id so the address stays one path segment', () => {
|
||||
const address = subagentChatAddress({
|
||||
parentSessionId: 'p',
|
||||
childSessionId: 'child/with:odd chars',
|
||||
mode: 'one-shot',
|
||||
})
|
||||
expect(address).toContain('/child%2Fwith%3Aodd%20chars?')
|
||||
})
|
||||
|
||||
it('never leaks the parent into the path (it is a routing query only)', () => {
|
||||
const address = subagentChatAddress({
|
||||
parentSessionId: 'p secret',
|
||||
childSessionId: 'c',
|
||||
mode: 'one-shot',
|
||||
})
|
||||
expect(address.split('?')[0]).toBe(`${SUBAGENT_CHAT_ADDRESS}c`)
|
||||
})
|
||||
|
||||
it('names the page kind the native Sidebar roots these pages under', () => {
|
||||
expect(SUBAGENT_CHAT_KIND).toBe('subagentchat')
|
||||
})
|
||||
|
||||
it('omits an unknown mode so the host treats it as "unknown" and skips the check', () => {
|
||||
// The degraded synthesized catalog guesses mode from `running`; a wrong
|
||||
// guess used to 401 the open (the controller compares address.mode against
|
||||
// the child's durable descriptor). An unrecognized mode must be dropped,
|
||||
// which the controller reads as `mode: "unknown"` and does not compare.
|
||||
const address = subagentChatAddress({
|
||||
parentSessionId: 'p',
|
||||
childSessionId: 'c',
|
||||
mode: 'unknown' as never,
|
||||
})
|
||||
expect(address).toBe('dsh-resource://subagentchat/session/c?parent=p')
|
||||
expect(address).not.toContain('mode=')
|
||||
})
|
||||
|
||||
it('still emits the mode when it is one of the two durable values', () => {
|
||||
expect(subagentChatAddress({ parentSessionId: 'p', childSessionId: 'c', mode: 'one-shot' }))
|
||||
.toContain('mode=one-shot')
|
||||
expect(subagentChatAddress({ parentSessionId: 'p', childSessionId: 'c', mode: 'continuable' }))
|
||||
.toContain('mode=continuable')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user