Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
061964e3e8 | ||
|
|
6b4a29ba24 | ||
|
|
35d33c7f65 | ||
|
|
aa8f417aa3 | ||
|
|
043340eb02 | ||
|
|
99bef0f877 | ||
|
|
66ee3953c9 | ||
|
|
5a715acb85 | ||
|
|
2b8ba87d81 | ||
|
|
a6dd8aa42a | ||
|
|
f4c44d13d6 | ||
|
|
0aafa94c4d | ||
|
|
df6f99c4a3 | ||
|
|
40a05af9ba | ||
|
|
2b88c85ea7 | ||
|
|
2224991047 | ||
|
|
63bcb47ed0 |
@@ -11,6 +11,9 @@
|
||||
> `apes:profile` 画像),按 DSH 标准 `TabDescriptor` 接口注册——因此它们会自动获得上游原生适配层
|
||||
> 的全部待遇(进原生 guide、tab 芯片图标、开合与分栏)。
|
||||
> - 版本线独立于上游:`1.2.0`(fork)。上游历史版本见其 CHANGELOG。
|
||||
> - **📚 项目百科**(需求分析 → 系统架构 → 功能说明 → 部署指南 → 运维手册 → 故障排查 → 开发与测试):
|
||||
> <https://gitea.apescale.com/figmar/apes-sidebar/wiki> 源文件在仓库 `docs/wiki/`,
|
||||
> 改完用 `bash scripts/push-wiki.sh` 一条命令发布。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **已适配 DSH 原生侧边栏 API**(v0.19.0 起,DSH `0.1.5-rc.1+`):右列就是 DSH 自己的右侧栏——插件的每个 tab 类型与 tab 体通过 `ctx.sidebarRightTabs` / `ctx.sidebarRight` 注册与打开,聊天里的文件打开统一走 `ctx.sidebarRight.openResource('dsh-resource://file/…')`,插件**不再自绘右侧面板**(旧的浮窗能力同步移除)。自绘的底部工作台与开放给其他插件的 `ctx.betterSidebar` 服务保持不变,接入方式见[插件接入指南](docs/external-plugin-guide.md)。
|
||||
|
||||
@@ -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)曾长期缺失,这才是「恒加载中」与「点不进去」的真根因;枚举与渲染层面本插件无缺口。
|
||||
@@ -0,0 +1,185 @@
|
||||
# 01 · 需求分析:官方原生右列 vs 装了 apes-sidebar
|
||||
|
||||
> 本篇只回答一个问题:**一个在 DSH 里干活的人,从"官方原生"换到"装了这个包",他的需求和处境发生了什么变化?**
|
||||
> 对比的两端只有两个:**官方原生的右侧栏**,和**装了 apes-sidebar 之后的右侧栏**(那个包是一个整体,九个 tab 一起算)。
|
||||
> 机制与根因只在[附录 A](#附录-a--证据与实现索引)留索引,细节见 [02-系统架构](02-系统架构)、[06-故障排查](06-故障排查)。
|
||||
|
||||
## 一、先钉住"谁在用、他在哪儿干活"
|
||||
|
||||
在 DSH 里,**右侧栏是使用者一天里盯得最久的一块地方**。它不是"偶尔打开的设置页",而是"工作台 + 仪表盘"。
|
||||
|
||||
一个典型使用者(在 DSH 里做项目的人):
|
||||
|
||||
- 中间是对话主线——他和模型一起干活;
|
||||
- 右边是这块地方——看文件、看改动、看子代理、顺手追问、跑命令、看预览;
|
||||
- 他往往同时开着好几个会话、好几个项目。
|
||||
|
||||
这决定了本页所有需求的共同特征:**这些信息"看一眼就够,但要频繁看"。**
|
||||
频繁看的东西必须常驻在视野里——不能要求他"跳出去看一眼,再跳回来"。
|
||||
|
||||
## 二、官方原生的右列:一个「只读的文件浏览器 + 预览器」
|
||||
|
||||
本机宿主(`dsh@0.1.5-rc.2`)里,官方真正放进右列的东西只有三件:
|
||||
|
||||
| 官方包 | 它给用户什么 | 官方原话 |
|
||||
| --- | --- | --- |
|
||||
| `dsh-client-ui-sidebar-right` | **容器**:每会话一块可停靠面板、两种形态(贴靠 / 全屏)、展开按钮在会话 header 角落、最多左右两格、拖拽与浮窗、tab 菜单、内置撤销历史 | "为每个会话持有一个停靠面……拥有导航控制器(`ctx.sidebarRight`)、tab 类型注册表" |
|
||||
| `dsh-client-ui-sidebar-files` | **唯一的页**:「文件」——把会话工作区根目录画成一棵**只读**的树 | "把会话的工作区根目录画成一棵树,逐层经线上列出,并把文件打开到 Sidebar 里" |
|
||||
| `dsh-client-ui-sidebar-documentpreview` | **唯一的查看器**(kind = `text`):预览 Markdown / 代码 / 图片 / PDF / HTML,认不出的扩展名走**纯文本兜底** | "在右侧 Sidebar 预览**可读文件**,无需另开 tab 即可切换已注册的渲染器" |
|
||||
|
||||
一个容易被忽略、但对"用户体验"影响很大的细节:
|
||||
|
||||
> 引导页(罗盘)**只在注册了两个以上入口时才出现**——官方随包只有一个入口(Files),
|
||||
> 所以官方用户的右列**默认直接就是那棵文件树,罗盘从不露面**。
|
||||
> (原文:"恰好一个入口时直接打开对应页面(随包组合中为 Files)"。)
|
||||
|
||||
**一句话概括官方原生的取向:它假设你只是想「看」。**
|
||||
它给你树和预览,都是只读的;**它不假设你要在这块地方「干活」**。
|
||||
|
||||
于是,官方原生用户在右列里**做不到**这些事(每一条都是他能直接感知的缺失):
|
||||
|
||||
| 他想做的事 | 官方原生右列 | 他只能怎么办 |
|
||||
| --- | --- | --- |
|
||||
| 改一个文件 | ❌ 只有预览,没有可写的编辑器 | 跳出去,用外部编辑器打开 |
|
||||
| 知道刚才改了哪些东西 | ❌ 没有变更清单、没有 diff | 切终端敲 `git status` / `git diff`,或另开 diff 工具 |
|
||||
| 看某个子代理干了什么 | ❌ 右列里没有这个入口 | 翻会话树、翻聊天记录去认人 |
|
||||
| 追问一句又不想弄脏主线 | ❌ 没有旁路对话 | 硬着头皮在主对话里问,或另开一个窗口 |
|
||||
| 跑一条命令 | ❌ 没有终端 | 切到系统终端,还得先 `cd` 到对的目录 |
|
||||
| 看页面 / HTML 出来什么样 | ❌ 没有内嵌浏览器 | 切浏览器标签,或手动打开产物文件 |
|
||||
| 知道谁在替我干活、跑到哪了 | ❌ 完全没有 | 只能往上翻记录,翻到一半又忘了上下文 |
|
||||
| 知道这摊活是在哪儿干的 | ❌ 界面不写 | 靠脑子记,或敲 `pwd` 确认 |
|
||||
| 知道这个会话现在什么状态 | ❌ 界面不写 | 靠界面上的小细节猜 |
|
||||
|
||||
## 三、装了 apes-sidebar 之后:同一块地方,从「看」变成「干 + 心里有数」
|
||||
|
||||
apes-sidebar 把这九件事一次性放进右列。它们**是一个整体需求**:把右列从"文件浏览器"变成"工作台 + 仪表盘"。
|
||||
|
||||
| tab | 他真正想要的是什么 | 官方原生时他被迫怎么做 | 装了之后 | 需求被改变成 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 文件(编辑器) | 看**和改**代码,而不丢掉当前上下文 | ⌘Tab 跳到外部编辑器;回来要重新找位置 | 右列里直接看、直接改 | 「看文件不该让我离开现场」 |
|
||||
| 文件变动(git) | 刚才到底改了哪些东西 | 切终端敲命令,或另开 diff 工具 | 变更清单 + 单文件 diff,就在旁边 | 「改了什么必须当场看得见」——**这是信任的前提** |
|
||||
| 子代理 | 进某个子代理,看它到底干了什么 | 翻会话树、翻记录去认人 | 右列里直接进入该子代理 | 「子代理是可进入的,不是只能干等的」 |
|
||||
| 侧边对话 | 追问一句,但不想污染主线 | 硬着头皮在主对话里问,或另开窗口 | 旁路开一个小对话,两边互不干扰 | 「提问不该有副作用」——**性质变了** |
|
||||
| 终端 | 跑一条命令 | 切到系统终端,常常还得先 `cd` | 右列里跑,工作目录天然就是对的 | 「命令的上下文应该跟着会话走」 |
|
||||
| 浏览器 / 预览 | 看这个页面出来是什么样 | 切浏览器标签,或手动打开产物 | 右列内嵌,当场看 | 「产出物要能当场验收」 |
|
||||
| **智能体** | 谁在替我干活、跑到哪了、哪个停了 | **基本没办法**——只能翻记录猜 | 主代理在最上,直系子代理一人一行,带运行/结束状态 | 「派了活之后,我要的是**扫一眼**,不是盯着守」(见 §四·洞察 4) |
|
||||
| **应用** | 这摊活到底是在哪儿干的 | **基本没办法**——靠脑子记或敲 `pwd` | 打开就看到当前会话锚定的工作区 | 「界面应该告诉我我在哪,而不是要我自己记住」 |
|
||||
| **画像** | 这个会话现在是什么状态 | 靠界面小细节猜(在跑?停了?哪一次?) | 标题 / 运行或空闲 / 工作区,写清楚 | 「离席回来,不用重建心智模型」 |
|
||||
|
||||
连**顺序**都在讲同一件事:`10…50` 是操作面(看/改/查/问/跑/验),`60…70` 是态势面(谁在干、在哪儿干、什么状态)。
|
||||
先让"活能在这儿干完",再让"我心里有数"——这是这个包对用户的两级承诺。
|
||||
|
||||
## 四、深度分析:需求到底发生了什么变化
|
||||
|
||||
### 洞察 0 · 官方原生假设"你只是看",而真实使用是"在这儿干活"
|
||||
|
||||
这是整个需求空间的起点。官方原生不是做得不好,而是**取向不同**:只读预览是"查看器"的思路,
|
||||
而使用者把它当"工位"用——他会在这里改一个文件、跑一条命令、瞄一眼改动。
|
||||
**当一块地方被当成工位,只读就会立刻变成绕路。** 这就是这个包存在的全部理由。
|
||||
|
||||
### 洞察 1 · 从「只读」到「可写」:补的不是功能,是主权的归还
|
||||
|
||||
官方只给预览,意味着**每一个"改一下"都要离开这块地方**。装上之后,改、查改动、跑、验四件事都在原地完成。
|
||||
用户省下的不是几次点击,而是**每次离开都要付出的"上下文重建"成本**——回来要重新找到刚才的位置、重新想起来自己在干嘛。
|
||||
需求因此从「我要能看」变成「我要在这儿把活干完」。
|
||||
|
||||
### 洞察 2 · 「留在原地」会变成默认预期(棘轮效应)
|
||||
|
||||
一旦右列里能改文件、能跑命令、能看预览,他对"跳出去一下"的容忍度会**永久下降**。
|
||||
他会开始说「为什么这个还要我切窗口?」——而这句话在他用官方原生时是说不出口的(因为原生本来就没有可切的东西)。
|
||||
**这类需求只前进、不后退**,所以值得一次做对:它一旦被填好,就变成基础设施。
|
||||
|
||||
### 洞察 3 · 消除副作用之后,他会开始"问"(侧边对话)
|
||||
|
||||
侧边对话的价值不在"多一个对话框",而在**把提问的代价从"可能污染主线"降到"零"**。
|
||||
需求的性质变了:以前他的问题是「要不要问?」(一个决策),现在变成「顺手问一下」(一个动作)。
|
||||
**当一件事的代价趋近于零,用户的行为频率会跳一个数量级**——这是需求分析里最值得警惕、也最值得利用的一条规律。
|
||||
|
||||
### 洞察 4 · 看得见 → 敢放手(智能体 tab 的核心价值)
|
||||
|
||||
这是整个包里最值钱的一条。官方原生在这一点上是**彻底空白**:
|
||||
|
||||
- 官方没有子代理视图,也没有任何"队伍"概念 → 他派了活之后**只能等**,或者不停往上翻记录确认;
|
||||
- 这个包的「子代理」让他能**进入**某一个;「智能体」让他**不必进去就看得到全局**——主代理 + 直系子代理,谁在跑、谁停了。
|
||||
|
||||
用户真正的痛点不是"我进不去子代理",而是**"我不敢派太多活"**:
|
||||
因为看不见,他只能用"盯着"来对冲风险,而盯着就失去了并行的意义。
|
||||
|
||||
> **看得见 → 敢放手 → 真的并行 → 才配得上"多智能体"。**
|
||||
> 需求从「我要能看见」升级为「我要能放心」。
|
||||
|
||||
### 洞察 5 · 心智模型从脑子里搬到界面上(应用 / 画像)
|
||||
|
||||
多项目、多会话的人切换时要重建一次"我这是在哪儿"——标题差不多的会话最容易看错,
|
||||
而看错的代价是**写错文件**。官方原生在右列里一个字都不写这件事。
|
||||
「应用」「画像」做的就是**把这份"我记得"外化成"界面写着"**。
|
||||
需求的性质:从「我要记性好」变成「界面不该让我记」。
|
||||
|
||||
### 洞察 6 · "引导页"第一次有了意义:从单一工具到工作台
|
||||
|
||||
官方原生只有一个入口(Files),所以它**默认直接开文件树,罗盘界面根本不会出现**。
|
||||
入口一多,罗盘才登场——用户第一次能"一屏看见这块地方能干什么"。
|
||||
这是**用户层面的分水岭**:官方是"一个工具",装了之后是"一个工作台的目录页"。
|
||||
(对照 §二那个细节:入口数决定了这块地方的形态。)
|
||||
|
||||
### 洞察 7 · 价值随"并行度"放大;而成本几乎为零
|
||||
|
||||
- 单会话、单项目的用户:「应用」「画像」几乎用不上,「智能体」也只有一个主代理;
|
||||
- 一旦同时开几个会话、派几个子代理、换几个项目:这三个 tab 的边际价值**陡增**。
|
||||
|
||||
所以这套东西**不是人人需要,但接到的人离不开**。
|
||||
同时它**不新增数据源、不新增进程、不引入新的失败面**——只是把宿主已经投影出来的信息换个视角呈现
|
||||
(逐条列在 [03-功能说明](03-功能说明))。对使用者意味着:**低风险地换到确定性**,这是它最该守住的边界。
|
||||
|
||||
## 五、由以上分析推出的需求(U1–U6)
|
||||
|
||||
| # | 需求(用户口吻) | 怎么算他满意了 | 现状 |
|
||||
| --- | --- | --- | --- |
|
||||
| U1 | 「我要在这儿把活干完,别老让我跳出去」 | 改文件、查改动、跑命令、看预览、顺手追问,都在右列完成 | ✅ 操作面六个 tab |
|
||||
| U2 | 「我要看得见谁在替我干活」 | 右列点一下就有队伍表;人数/状态一眼可辨;没有子代理时明确说"没有",不是空白 | ✅ 智能体 tab |
|
||||
| U3 | 「我要知道我是在哪儿干的活、这会话什么状态」 | 打开就看到工作区与运行/空闲,不用敲 `pwd`;拿不到应用信息时如实说明 | ◑ 工作区/状态 ✅ · 应用层等宿主 |
|
||||
| U4 | 「我点开 HTML 就该看得见」 | 自包含 HTML 完整显示,而不是一行 `forbidden`;**但不许为此拆掉沙箱** | ✅(多文件需我本人点「临时解锁」) |
|
||||
| U5 | 「我的界面不许自己乱套」 | 右列只有一套侧边栏、一个开合入口;弹窗完整盖在面板之上、按钮点得到 | ✅ |
|
||||
| U6 | 「我敢升级,也退得回去」 | 有"宿主版本 ↔ 插件版本"对照表;每步升级都有分支/tag 可回滚 | ✅ 两条版本线;新宿主线待实机验证 |
|
||||
|
||||
## 六、我们不做的事(省得期待错位)
|
||||
|
||||
- **不重造官方已有的东西。** 官方给了容器与文件树,我们在它里面长东西,不另起一套侧边栏。
|
||||
- **不为"看得见"放宽安全边界。** 需要放宽必须由用户**本人显式点**「临时解锁」。
|
||||
- **不做团队/房间/多智能体协作编排。** 那是宿主的另一类职责。
|
||||
- **不承诺"装上立刻见效"。** 单会话用户可能几乎感觉不到差异(见洞察 7)。
|
||||
|
||||
## 七、已知的「暂时满足不了」(诚实交代)
|
||||
|
||||
| 他想要的 | 现状 | 为什么(一句话) | 何时会有 |
|
||||
| --- | --- | --- | --- |
|
||||
| 知道这摊活属于哪个**应用**、有哪些入口 | 「应用」只显示工作区 | 宿主目前没有向插件提供应用清单的通道 | 宿主提供 feed 后即可接上 |
|
||||
| 一次摊开**完整**子代理树(子代理的子代理) | 只显示**直系** | 宿主投影只给一层 | 同上 |
|
||||
| 沙箱状态下多文件 HTML 的相对资源 | 需点「临时解锁」 | 不透明源沙箱下子请求拿不到宿主信任头(安全边界的必然结果) | 不打算改——这是刻意保留的边界 |
|
||||
|
||||
> 这三条说明同一件事:**需求跑在宿主能力前面**。写出来不是免责,是让读的人知道
|
||||
> "不能用"和"还没做"是两回事。
|
||||
|
||||
## 附录 A · 证据与实现索引
|
||||
|
||||
开发与排障看这里;其余读者可跳过。
|
||||
|
||||
**"官方原生到底有什么"的证据**(本机宿主 `dsh@0.1.5-rc.2`,官方包自带文档):
|
||||
|
||||
- 容器:`@deepseek-ai/dsh-client-ui-sidebar-right/README.zh.md` —— 停靠面、两种形态、扩展席位、引导页。
|
||||
- 唯一的页:`@deepseek-ai/dsh-client-ui-sidebar-files/README.zh.md` —— "文件树 tab 类型……把文件打开到 Sidebar 里"。
|
||||
- 唯一的查看器:`@deepseek-ai/dsh-client-ui-sidebar-documentpreview/README.zh.md` —— "Sidebar tab 的 kind 为 `text`",未知扩展名纯文本兜底。
|
||||
- "官方只有一个入口 ⇒ 罗盘不出现":同上 sidebar-right 文档「引导页」一节。
|
||||
|
||||
**本包一侧的对应实现**(`editor`/`git`/`subagent`/`sidechat`/`terminal`/`browser` 为随包操作面;
|
||||
`agents`/`apps`/`profile` 是本 fork 自增的态势面——对内维护用,对外它是一个包):
|
||||
|
||||
| 需求 | 机制(去哪看) | 关键证据 |
|
||||
| --- | --- | --- |
|
||||
| U5-a 右列只有一套侧边栏 | 每个 tab 注册成原生 tab 类型(`kind = descriptor.id`)渲染进官方右列;自绘面板已移除 | [02-系统架构](02-系统架构)、[05-运维手册](05-运维手册) |
|
||||
| U5-b 弹窗不被面板切 | 宿主容器上的层叠上下文(`isolation`)导致;挂载点改挂 `#root` 而不再挂 `document.body` | [06-故障排查](06-故障排查) |
|
||||
| U4 HTML 预览 | 主框架 `fetch` → `srcDoc`,**`sandbox` 属性未动**;失败回退路由 URL | [06-故障排查](06-故障排查) |
|
||||
| 与官方 `text` 预览的关系 | 本包的编辑器类型以更高优先级接管 `dsh-resource://file/**`,卸载即恢复官方预览 | `docs/external-plugin-guide.md` §「内置类型接管」 |
|
||||
| U6 版本线 | 宿主版本不匹配时宿主**静默禁用**整行(不报错、无日志);`^` 跨 minor 判 false | [04-部署指南](04-部署指南)、[06-故障排查](06-故障排查) |
|
||||
|
||||
<!-- 本页只讲"为什么需要"。实现细节、提交号、测试名一律留在 02–07 与附录索引里。 -->
|
||||
@@ -0,0 +1,154 @@
|
||||
# 02 · 系统架构
|
||||
|
||||
> 本篇说明插件的**分层、接口面、层叠契约与关键不变量**。
|
||||
> 与源码一一对应,可直接当作改动前的「雷区地图」使用。
|
||||
|
||||
## 一、总体分层
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ DSH 宿主(Desktop Electron 壳 / Web profile) │
|
||||
│ ├─ 原生右侧栏 ctx.sidebarRight / sidebarRightTabs(tab 类型注册表) │
|
||||
│ ├─ 会话与任务 ctx.sessions.list(会话摘要、子代理、投影)、ctx.tools │
|
||||
│ ├─ webServer 路由挂载点(插件用 /sidebar/* 前缀注册) │
|
||||
│ └─ 桌面壳层 webserver 外层的请求围栏 + #root 的 content-viewport 标记 │
|
||||
└───────────────┬──────────────────────────────────────────────────────────┘
|
||||
│ ① 宿主 → 插件:cordis 服务注入(inject)
|
||||
│ ② 插件 → 宿主:原生 tab / 资源打开 / 路由
|
||||
┌───────────────┴──────────────────────────────────────────────────────────┐
|
||||
│ 插件 HOST 半(Node,lib/index.js) │
|
||||
│ · src/index.ts 路由注册(/sidebar/*)、信任围栏、会话/文件数据、HTML 交付 │
|
||||
│ · src/trust-fence.ts 请求信任围栏(Host 头 / 令牌) │
|
||||
│ · src/invariant.ts 依赖零的断言与 semver 工具(host/client 共用) │
|
||||
└───────────────┬──────────────────────────────────────────────────────────┘
|
||||
│ 浏览器侧模块(__ModuleLoader__ bundle,非 CJS)
|
||||
┌───────────────┴──────────────────────────────────────────────────────────┐
|
||||
│ 插件 CLIENT 半(浏览器,lib/client.js + 懒加载 chunk) │
|
||||
│ · src/client/index.tsx 应用入口:装配 store/service/原生适配/信息层 │
|
||||
│ · src/client/native/ 原生右侧栏适配(tab 记录、tab 体、tab 芯片标题) │
|
||||
│ · src/client/builtins/ 内置面板:文件 / 文件变动 / 子代理 / 侧边对话 / │
|
||||
│ 终端 / 浏览器 + 文件预览器 │
|
||||
│ · src/client/sidebar/ 自绘底部工作台与开合控件(原生侧边栏之外的部分) │
|
||||
│ · src/client/apes/ 信息层五 tab(本 fork 的增量) │
|
||||
│ · src/client/service.ts ctx.betterSidebar 服务(供其他插件注册) │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 二、构建产物
|
||||
|
||||
| 产物 | 角色 | 备注 |
|
||||
| --- | --- | --- |
|
||||
| `lib/index.js` | host 半(ESM,Node) | 路由、围栏、数据面 |
|
||||
| `lib/invariant.js` | 不变量面(零依赖) | host/client 都要 import,**不得**引入 Node 内置或宿主包 |
|
||||
| `lib/client.js` | client 主 bundle | 浏览器 `window.__ModuleLoader__.load({...})` 形态,**不是** CJS/ESM,Node 里 `require` 会因 `window is not defined` 抛错 |
|
||||
| `lib/client-registry.js` | 懒加载 chunk | 高开销能力(图标/预览库)按需加载 |
|
||||
| `lib/client-editor.js` | 懒加载 chunk | 编辑器(CodeMirror 系) |
|
||||
| `lib/client-locale.js` | 懒加载 chunk | 语言包 |
|
||||
| `lib/client-mermaid.js` | 懒加载 chunk | Mermaid 渲染 |
|
||||
| `lib/client-terminal.js` | 懒加载 chunk | 终端 |
|
||||
| `lib/types/**/*.d.ts` | 类型声明 | 供消费方类型检查 |
|
||||
|
||||
> 懒加载动机:主 bundle 不应静态 import 重型预览库;chunk 通过宿主 `ctx.modules` 解析外部依赖
|
||||
> (入口在 `src/client/index.tsx` 里先 `setChunkModuleSystem(ctx.modules)`)。
|
||||
|
||||
## 三、三层接口面
|
||||
|
||||
### 3.1 宿主 → 插件(cordis 注入)
|
||||
|
||||
```ts
|
||||
// src/index.ts
|
||||
export const name = 'dsh-apes-sidebar'
|
||||
export const inject = ['webServer', 'sessions', 'webRuntime', 'tools']
|
||||
export function apply(ctx: Context, config?: SidebarConfig): void
|
||||
```
|
||||
|
||||
客户端侧读取的宿主面(结构化镜像,运行时由宿主提供):
|
||||
`ctx.sessions.list`(`getSnapshot/subscribe`)、`ctx.sidebarRight`(原生右列与 `openResource`)、
|
||||
`ctx.slots`、`ctx.settings`(挂载行 `config`)、`ctx.modules`(懒加载 chunk 的模块系统)。
|
||||
|
||||
### 3.2 插件 → 宿主
|
||||
|
||||
| 方向 | 手段 |
|
||||
| --- | --- |
|
||||
| 注册 tab 类型与 tab 体 | `src/client/native/registerNativeSurface()` → 原生右侧栏注册表 |
|
||||
| 打开会话内文件 | `ctx.sidebarRight.openResource('dsh-resource://file/…')` |
|
||||
| 暴露 HTTP 能力 | `/sidebar/` 前缀路由:`api` · `file` · `html` · `upload` · `ws/terminal` · `ws/agent-opens` · `ws/agent-terminals` |
|
||||
|
||||
### 3.3 插件 → 其他插件(对外服务)
|
||||
|
||||
`ctx.betterSidebar`(`src/client/service.ts`)对外开放 `registerTab` / `registerFileViewer` 等,
|
||||
并按「能力清单」做前向兼容门控(能力只增不减)。对外契约见仓库内
|
||||
[docs/external-plugin-guide.md](../blob/main/docs/external-plugin-guide.md)。
|
||||
服务版本常量 `SIDEBAR_SERVICE_VERSION` 必须与 `package.json` / `dsh.plugin.json` 的版本三方一致
|
||||
(有单元测试断言这一对)。
|
||||
|
||||
## 四、面板宿主与层叠契约(重点雷区)
|
||||
|
||||
插件的所有面板渲染在一个 **body 级(或壳层 viewport 级)的宿主层**里:
|
||||
|
||||
```css
|
||||
[data-dsh-panel-host] { position: fixed; inset: 0; z-index: 25; pointer-events: none }
|
||||
```
|
||||
|
||||
`z-index: 25` 不是随手取的,它要同时满足:**在 app 内容与壳层 overlay 之上、在宿主交给插件用的
|
||||
overlay 槽之下**。完整取义:
|
||||
|
||||
| 层级 | 对象 |
|
||||
| --- | --- |
|
||||
| `z-index: auto` | app 的流内内容、`.dshDesktopFrame` |
|
||||
| **25** | **本插件的面板宿主** |
|
||||
| 30 | 宿主的动态插件面板(ui-cordis) |
|
||||
| 100+ | app 的 overlay 栈(Toast / onBoarding 等) |
|
||||
| 1000 | `.dshDesktopOverlay[data-shell-overlay]`(宿主给 `shell.overlay` 槽的容器,第三方弹窗在此) |
|
||||
|
||||
**陷阱(已修,`4422001`)**:DSH 桌面壳的有框模式会给上游 `#root` 打标
|
||||
`data-dsh-desktop-content-viewport` 并施加 `isolation: isolate`,使 `#root` 成为一个**层叠上下文**。
|
||||
宿主自己的 overlay 层就在这个上下文内;若插件宿主挂在 `document.body`(上下文之外),
|
||||
它是「带正 z-index 的定位元素」,会画在整个上下文**之上**——把宿主 overlay 里的弹窗(如社区插件市场)盖住。
|
||||
|
||||
```ts
|
||||
// src/client/desktop-env.ts —— 修法:挂进壳层的 content viewport
|
||||
export function panelHostParent(doc: Document = document): HTMLElement {
|
||||
return doc.querySelector<HTMLElement>('#root[data-dsh-desktop-content-viewport]') ?? doc.body
|
||||
}
|
||||
```
|
||||
|
||||
进入该上下文后,25 恢复原意(在 app frame 之上、在 overlay 层 1000 之下);无该标记的部署
|
||||
(纯网页 / 未打标的壳)仍挂 `document.body`,行为不变。重新锚定守卫同时监听
|
||||
`document.body` 的 `data-dsh-desktop-mode`(壳层打标与它是同一个 effect),因此壳层在插件挂载**之后**
|
||||
才框住 `#root` 的情况也能被搬到正确位置。
|
||||
|
||||
## 五、数据流示例
|
||||
|
||||
**会话 → 信息层**
|
||||
|
||||
```
|
||||
ctx.sessions.list.getSnapshot() → { byId, projectionsBySession? }
|
||||
useSyncExternalStore(subscribe) (uSES 安全订阅)
|
||||
├─ apes:agents → rootAncestor(byId, scope.sessionId) 找主代理
|
||||
│ → projectionsBySession[rootId].values.subagentCatalog 取直系子代理
|
||||
│ (无投影时回退 byId 里的 durable 子会话)
|
||||
└─ apes:profile / apes:apps → byId[scope.sessionId](标题 / cwd / running)
|
||||
```
|
||||
|
||||
**文件 → 预览**(HTML 为例)
|
||||
|
||||
```
|
||||
编辑器/diff 面板 → /sidebar/html/<session>/<path> (路由负责路径围栏与体积上限)
|
||||
主框架 fetch 取回文档文本 → 注入 <base href="<该文件的路由目录>">
|
||||
→ iframe srcDoc + sandbox(不透明源)
|
||||
取失败时回退为 iframe src=路由 URL,把路由的答复原样透出
|
||||
```
|
||||
|
||||
## 六、关键不变量(改动前必读)
|
||||
|
||||
1. **host / client 拆分是结构性的**:client bundle 在浏览器运行,不得 import Node 内置;
|
||||
`src/invariant.ts` 两侧共用,必须保持零依赖。
|
||||
2. **`dsh-better-sidebar` 是 peer,永不打包**:一旦被内联,client 会带上第二份侧边栏注册表,
|
||||
tab 注册进死实例,界面上永不出现。
|
||||
3. **tab id 前缀归本 fork**:信息层用 `apes:*`;不得占用上游/宿主内部 id。
|
||||
4. **HTML 预览的沙箱令牌不得放宽**(不加 `allow-same-origin` 等);交付方式只能是「主框架取文档 + `srcDoc`」。
|
||||
5. **颜色只用宿主设计令牌**(`--dsw-alias-*`),不写硬编码色值;几何量(字号/间距/圆角)无公开令牌,内联即可。
|
||||
6. **所有面向用户的字符串走 i18n**,`zh` / `en` 必须同时存在。
|
||||
7. **变更后必须重新同步到 profile**:构建产物非字节确定性,且 profile 的 `file:` 依赖是复制语义,
|
||||
不能假定旧副本可用(见 [04-部署指南](04-部署指南))。
|
||||
@@ -0,0 +1,118 @@
|
||||
# 03 · 功能说明
|
||||
|
||||
> 本篇是**功能清单与行为契约**:有哪些面板、数据从哪来、文案与配色怎么走、安全边界在哪、能配什么。
|
||||
|
||||
## 一、面板与标签页总表
|
||||
|
||||
「来源」列:**上游** = 来自 `dsh-better-sidebar`;**宿主** = DSH 自身提供并占用该槽位;**本 fork** = 本项目的增量。
|
||||
|
||||
| id | 名称 | order | 来源 | 数据 / 行为 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `editor` | 文件 | 10 | 上游 | 文件树 + 编辑器(CodeMirror 系,懒加载 `client-editor`) |
|
||||
| `git` | 文件变动 | 20 | 上游 | 工作区变更、单个 diff 视图 |
|
||||
| `subagent` | 子代理 | 30 | 上游 | 会话的子代理视图 |
|
||||
| `sidechat` | 侧边对话 | 35 | 上游 | 会话内旁路对话 |
|
||||
| `terminal` | 终端 | 40 | 上游 | PTY 终端(懒加载 `client-terminal`;v0.24.1 线起由宿主内置终端承担) |
|
||||
| `browser` | 浏览器 | 50 | 上游 | 内嵌浏览器/HTML 预览宿主 |
|
||||
| `diff` | (内部) | -1 | 上游 | 非用户可见,供内部打开 diff |
|
||||
| **`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 是上游/宿主既定带宽,apes 信息层取 55/60/65/67/70 留出余量,避免与原生面板抢占位置。
|
||||
|
||||
## 二、信息层五 tab(本 fork 的增量)
|
||||
|
||||
五个 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` 删除)后的落地方式。
|
||||
|
||||
## 三、文件预览器
|
||||
|
||||
| id | 适用 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `image` | 图片 | 走媒体路由,受 `mediaLimit` 限制 |
|
||||
| `pdf` | PDF | 内嵌查看 |
|
||||
| `markdown` | Markdown | 渲染(Mermaid 走懒加载 chunk,可手动刷新) |
|
||||
| `html` | HTML | **不透明源沙箱 iframe**,见下节 |
|
||||
| `code` | 代码 | 高亮查看(编辑器懒加载) |
|
||||
| `binary-download` | 二进制 | 只提供下载 |
|
||||
|
||||
### 3.1 HTML 预览的交付方式(重要)
|
||||
|
||||
```
|
||||
路由 /sidebar/html/<session>/<path> ← 路径围栏 + 体积上限(readLimit)
|
||||
│
|
||||
├─ 主框架 fetch(唯一同时被宿主围栏信任、又能带上 Electron 头的角色)
|
||||
│
|
||||
├─ 注入 <base href="<该文件的路由目录>"> ← 让 ./style.css 等相对引用解析回本文件所在目录
|
||||
│
|
||||
└─ iframe srcDoc=<文档> + sandbox=HTML_IFRAME_SANDBOX
|
||||
```
|
||||
|
||||
| 状态 | 自包含 HTML | 多文件 HTML(外链 CSS/JS/图片) |
|
||||
| --- | --- | --- |
|
||||
| 沙箱态(默认) | ✅ 完整可用 | 正文可用,**相对资源不可用**(iframe 源为 `null`,子请求同样被宿主围栏拒绝) |
|
||||
| 临时解锁(无 sandbox 属性) | ✅ | ✅ **相对资源可用**(iframe 继承 GUI 源,成为普通同源请求) |
|
||||
|
||||
设计取舍:**沙箱令牌一个字符都不放宽**。多文件 HTML 想要完整效果需用户显式点「临时解锁」——
|
||||
这是把「放宽边界」的决定权留给用户的显式动作,而不是插件默认降级。
|
||||
|
||||
## 四、安全边界(三层)
|
||||
|
||||
| 层 | 机制 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 预览内容 | `sandbox="allow-scripts allow-popups allow-downloads allow-modals"` | **不含 `allow-same-origin`**:预览文档运行在不透明源里。宿主路由另有 CSP `sandbox` 兜底 |
|
||||
| 插件路由 | 请求信任围栏(`src/trust-fence.ts`) | 校验 Host 头/令牌,拒绝跨站或非授权来源对 `/sidebar/*` 的访问 |
|
||||
| 桌面壳 | Electron 注入的 `x-dsh-desktop-renderer` 头 | 扩展模式下所有路由的前置围栏;只发给主框架与同源子框架。这正是「沙箱 iframe 发不出请求」的根源 |
|
||||
|
||||
## 五、i18n 与主题
|
||||
|
||||
- **语言包**:`src/client/locales.ts` + 19 个 `src/client/locales-*.ts` 覆盖文件;默认 `zh`,缺省回退 `en`。
|
||||
- **本 fork 文案**:集中在 `src/client/apes/locales.ts`,每个 key 同时提供 `zh` / `en`。
|
||||
- **硬性约定**:界面里**不得出现裸字符串**,一律 `t('key')`。
|
||||
- **配色**:只用宿主设计令牌 `--dsw-alias-*`(bg / border / label / state / interactive 系列),
|
||||
写法为 `var(--dsw-alias-xxx, <字面回退>)`,保证在非 DSH 环境(单元测试)也能渲染。
|
||||
**无公开的 `--ds-*` 几何令牌**,字号/间距/圆角内联即可。
|
||||
|
||||
## 六、配置面(挂载行 `config`)
|
||||
|
||||
配置写在插件挂载行上(profile 的 `cordis.patch.yml` 或 bundle patch 的 insert 行),设置页写的也是它:
|
||||
|
||||
| 字段 | 默认 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| `readLimit` | `512 KiB` | 单文件读取上限(超出截断并标记 `truncated`) |
|
||||
| `mediaLimit` | `20 MiB` | 媒体(图片等)读取上限 |
|
||||
| `uploadLimit` | `128 MiB` | 上传上限 |
|
||||
| `listLimit` | `1000` | 目录列举条目上限 |
|
||||
| `terminalsPerSession` | `3` | 每会话终端数上限 |
|
||||
| `shell` / `shellArgs` | 自动解析 | 终端 shell 与其启动参数(`shellArgs` 非空时替换默认 `-l`) |
|
||||
|
||||
例:在挂载行里指定 shell
|
||||
|
||||
```yaml
|
||||
- insert:
|
||||
- id: better-sidebar
|
||||
name: 'dsh-apes-sidebar'
|
||||
config:
|
||||
shell: /bin/zsh
|
||||
shellArgs: ['--noprofile', '--no-rc']
|
||||
```
|
||||
@@ -0,0 +1,163 @@
|
||||
# 04 · 部署指南
|
||||
|
||||
> 目标:在一台机器上把本插件挂进 DSH,并确认它**真的在跑**。
|
||||
> 关键前提:**先确认宿主版本,再选版本线**——选错线的唯一症状是「静默失效」。
|
||||
|
||||
## 一、前置条件
|
||||
|
||||
| 项 | 要求 |
|
||||
| --- | --- |
|
||||
| Node | ≥ 20(`engines.node`);本机验证用 22.x |
|
||||
| pnpm | 11.x(`pnpm install` 会自动触发 `prepare: tsdown` 构建) |
|
||||
| DSH | 见下面的版本矩阵 |
|
||||
| 终端可用性 | 仅 v0.19.1 线需要额外放行 `node-pty` 原生构建(见 §五) |
|
||||
|
||||
### 1.1 宿主版本矩阵(先读这张表)
|
||||
|
||||
| 版本线 | 分支 | 插件版本 | peer 声明 | 适用宿主 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 旧宿主线 | `main` | 1.2.0 | `^0.1.5-rc.1` | DSH `0.1.5-rc.1+`(含 `0.1.5-rc.2`,即 **DSH Desktop 2.0.10** 内置版本) |
|
||||
| 新宿主线 | `sync/v0.24.1` | 1.3.0 | `^0.2.0-rc.1` | DSH `0.2.0-rc.1+` |
|
||||
|
||||
**怎么查宿主版本**(扩展模式桌面版):
|
||||
|
||||
```bash
|
||||
# 内置的 dsh 版本
|
||||
grep -m1 '"version"' "/Applications/DSH Desktop.app/Contents/Resources/app/node_modules/@deepseek-ai/dsh/package.json"
|
||||
# 或看 profile 里装的宿主包
|
||||
grep -m1 -A2 '"@deepseek-ai/dsh"' ~/.dsh/profiles/desktop/package.json
|
||||
```
|
||||
|
||||
**选线决策**
|
||||
|
||||
```
|
||||
宿主 ≥ 0.2.0-rc.1 ? ── 是 ──▶ sync/v0.24.1(1.3.0)
|
||||
└─ 否 ──▶ main(1.2.0)
|
||||
```
|
||||
|
||||
> ⚠️ **peer 不匹配不会报错。** 宿主以 `semver.satisfies(host, peerRange, { includePrerelease: true })` 预检,
|
||||
> caret 跨 minor 判 false 后**整行静默禁用**:不加载、无日志、界面上「像没装」。
|
||||
> 因此「装完没反应」的第一件事永远是核对宿主版本 ↔ peer 范围。
|
||||
|
||||
## 二、安装方式(本 fork 未发布 npm 包)
|
||||
|
||||
`npm view dsh-apes-sidebar` 返回 404 —— **本 fork 不在 npm 上**(README 里的 npm 徽章/命令是沿用上游文档)。
|
||||
实际部署使用「本地源码 + `file:` 链接依赖」:
|
||||
|
||||
### 步骤 1:构建
|
||||
|
||||
```bash
|
||||
cd <插件仓库绝对路径>
|
||||
git checkout main # 或 sync/v0.24.1(按宿主版本选)
|
||||
pnpm install # 会顺带跑 prepare: tsdown
|
||||
pnpm typecheck && pnpm build && pnpm test # 门禁三连
|
||||
```
|
||||
|
||||
产物(`lib/`):`index.js` / `invariant.js`(host)+ `client.js` + 懒加载 chunk
|
||||
(`client-registry` / `client-editor` / `client-locale` / `client-mermaid` / `client-terminal`)+ `types/`。
|
||||
|
||||
### 步骤 2:接进 profile
|
||||
|
||||
编辑 `~/.dsh/profiles/<profile>/package.json`(桌面版为 `desktop`,网页版通常为 `web`):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"dependencies": {
|
||||
"dsh-apes-sidebar": "file:/绝对路径/dsh-apes-sidebar" // ← 必须是绝对路径
|
||||
},
|
||||
"dsh": {
|
||||
"profile": {
|
||||
"bundles": [
|
||||
"…",
|
||||
"dsh-apes-sidebar" // ← 加入 bundle 栈
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 也可以在 profile 自己的 `cordis.patch.yml` 里手写挂载行(老方式)。**两种方式不要同时用**:
|
||||
> 会双挂载成两个 Node 半、两个侧边栏。包内自带的 `cordis.patch.yml` 是 `dsh.bundle.patch` 层,
|
||||
> 由官方 CLI 安装时自动追加 bundle,并带「聚合包双挂载退让」守卫。
|
||||
|
||||
### 步骤 3:让 profile 重新安装依赖
|
||||
|
||||
```bash
|
||||
cd ~/.dsh/profiles/<profile> && pnpm install
|
||||
```
|
||||
|
||||
**为什么必须重装**:pnpm 对 `file:` 依赖是**复制**语义(不是软链)。源码 `pnpm build` 之后,
|
||||
profile 的 `node_modules/dsh-apes-sidebar/lib` **仍是旧副本**——这就是「改了代码但界面没变」的头号原因。
|
||||
DSH 启动时也会 reconcile profile 的 pnpm workspace,但不要假定它一定帮你刷新。
|
||||
|
||||
### 步骤 4:完整重启
|
||||
|
||||
**⌘Q 完全退出 DSH,再重新启动**(不是关窗口)。client 半有热加载,但 host 半与依赖变更必须走完整重启。
|
||||
|
||||
## 三、验收清单
|
||||
|
||||
重启后逐条核对(每条都对应一个可观察事实):
|
||||
|
||||
- [ ] **右列只有一套侧边栏**、一个开合控件(不再出现自绘的第二套开关)
|
||||
- [ ] 原生 guide 里能看到本插件的 tab 芯片,且**带图标**
|
||||
- [ ] 三个信息层 tab 存在:**智能体 / 应用 / 画像**(order 在原生面板之后)
|
||||
- [ ] `apes:agents` 打开后:有主代理卡片;有子代理时列出直系子代理(标签 + 模式 + 运行态)
|
||||
- [ ] 打开一个 HTML 文件:**沙箱态下自包含 HTML 能正常渲染**(不再是 `forbidden`)
|
||||
- [ ] 打开「社区插件市场」:弹窗**完整盖在**本插件面板之上(面板不切弹窗角)
|
||||
- [ ] 页面左下角**没有**红色诊断条(有的话说明 client 生命周期某处抛错)
|
||||
- [ ] 开发者控制台**没有** `[dsh-apes-sidebar] … error` 前缀的错误
|
||||
|
||||
## 四、网页 profile 与桌面 profile 的差异
|
||||
|
||||
| 项 | 桌面版(DSH Desktop) | 网页版 |
|
||||
| --- | --- | --- |
|
||||
| profile 目录 | `~/.dsh/profiles/desktop` | 通常 `~/.dsh/profiles/web` |
|
||||
| 外壳 | Electron 壳,有「扩展模式/兼容模式」与请求围栏 | 浏览器直连宿主端口 |
|
||||
| 预览 iframe | 受渲染器围栏约束(因此必须走 `srcDoc` 交付) | 无该围栏,但仍受路由 CSP/沙箱约束 |
|
||||
| 面板宿主层级 | 壳层会给 `#root` 打 content-viewport 标记 → 宿主挂进 `#root` | 无标记 → 宿主挂 `document.body` |
|
||||
| 刷新方式 | ⌘Q 完整退出再启动 | 硬刷新(Cmd/Ctrl+Shift+R) |
|
||||
|
||||
## 五、v0.19.1 线特有:放行 node-pty 原生构建
|
||||
|
||||
`main`(上游 v0.19.1 基线)在 `package.json` 中仍有 `node-pty` 依赖;pnpm 11 默认**拦截依赖的构建脚本**,
|
||||
于是首次安装会失败或终端不可用:
|
||||
|
||||
```bash
|
||||
cd ~/.dsh/profiles/<profile>
|
||||
pnpm approve-builds # 选中 node-pty,回车确认
|
||||
pnpm rebuild node-pty # 必要时
|
||||
```
|
||||
|
||||
`sync/v0.24.1`(1.3.0)**不含** `node-pty`(终端交由宿主内置承担),无需此步。
|
||||
(`main` 与 `sync/v0.24.1` 的 `node-pty` 引用数:1 / 0。)
|
||||
|
||||
## 六、切换与卸载
|
||||
|
||||
同一条线只能装一个(两条线都注册 `ctx.betterSidebar`,entry id 冲突,第二个被丢弃)。
|
||||
仓库自带切换脚本:
|
||||
|
||||
```bash
|
||||
bash scripts/switch.sh --status # 只看当前是哪条线
|
||||
bash scripts/switch.sh aps # 切到本 fork 线(本地 file: 安装)
|
||||
bash scripts/switch.sh upstream # 切回上游 dsh-better-sidebar
|
||||
bash scripts/switch.sh upstream 0.18.0 # 指定上游版本
|
||||
bash scripts/switch.sh aps --profile web # 指定 profile
|
||||
```
|
||||
|
||||
**完全卸载**:从 profile 的 `dependencies` 与 `dsh.profile.bundles` 中移除 `dsh-apes-sidebar`,
|
||||
`pnpm install`,⌘Q 完整重启。若曾手写过 `cordis.patch.yml` 挂载行,一并删除(否则会留下悬空 entry)。
|
||||
|
||||
> 若同时装了聚合包(如 `@linxin666/dsh-web-ui-all`)且它内部也挂载了本包,
|
||||
> 包内 bundle patch 的守卫会让**本行退让**;此时请确认聚合包在 bundle 栈中位于本行之前。
|
||||
|
||||
## 七、变更后的重新部署
|
||||
|
||||
```bash
|
||||
# 插件仓库
|
||||
pnpm typecheck && pnpm build && pnpm test
|
||||
# profile
|
||||
cd ~/.dsh/profiles/<profile> && pnpm install
|
||||
# ⌘Q 退出 DSH 再启动
|
||||
```
|
||||
|
||||
改完代码**必须**走完这三步:产物构建非字节确定性,profile 的 `file:` 副本也不会自动跟随。
|
||||
@@ -0,0 +1,133 @@
|
||||
# 05 · 运维手册
|
||||
|
||||
> 本篇是**日常运维、升级与回滚**的操作手册。所有命令都以「先留锚点、再动手、改完回读验证」为原则。
|
||||
|
||||
## 一、版本线与分支策略(当前远端实况)
|
||||
|
||||
| ref | 指向 | 含义 / 用途 |
|
||||
| --- | --- | --- |
|
||||
| `main` | `4422001` | **旧宿主线**:插件 1.2.0,上游基线 v0.19.1,peer `^0.1.5-rc.1`。本机 DSH Desktop 2.0.10 正在跑这条 |
|
||||
| `sync/v0.24.1` | `e5bbef1` | **新宿主线**:插件 1.3.0,上游基线 v0.24.1,peer `^0.2.0-rc.1`。待 ≥0.2.0 宿主实机验证 |
|
||||
| `backup/pre-sync-v0.19.1` | `96cc334` | v0.19.1 re-base **之前**的状态(旧 fork 三角色 tab 版) |
|
||||
| `backup/pre-sync-v0.24.1` | `4422001` | v0.24.1 re-base 之前的 `main`(等价于当前 main,作为下次升级的锚点) |
|
||||
| `tags/apes-v1.1.0-pre-sync` | `96cc334` | 与上面第一支 backup 同一提交,tag 形式的锚点 |
|
||||
| `tags/apes-v1.2.0` | `4422001` | 当前已验收的 1.2.0 线 |
|
||||
|
||||
**纪律**:任何 re-base / 大改之前,先建 `backup/pre-sync-<新基线>` 分支 + `apes-v<版本>` tag,
|
||||
并**推送**到远端(本地锚点在多人/多机场景等于不存在)。
|
||||
|
||||
## 二、回滚剧本
|
||||
|
||||
### 场景 A:某次提交引入回归(单点回退)
|
||||
|
||||
```bash
|
||||
cd <插件仓库>
|
||||
git log --oneline -5 # 定位可疑提交
|
||||
git revert <sha> # 保留历史的回退(推荐)
|
||||
# 或整体丢弃:git reset --hard <上一提交>
|
||||
pnpm typecheck && pnpm build && pnpm test
|
||||
cd ~/.dsh/profiles/<profile> && pnpm install # file: 是复制语义,必须重装
|
||||
# ⌘Q 完全退出 DSH → 重启
|
||||
```
|
||||
|
||||
### 场景 B:整条线回退(例如新线在真机上不可用)
|
||||
|
||||
```bash
|
||||
git checkout main # 或 backup/pre-sync-v0.24.1
|
||||
pnpm install && pnpm build
|
||||
cd ~/.dsh/profiles/<profile> && pnpm install
|
||||
# ⌘Q 重启
|
||||
```
|
||||
|
||||
### 场景 C:profile 依赖/config 被改坏
|
||||
|
||||
```bash
|
||||
cd ~/.dsh/profiles/<profile>
|
||||
cp package.json package.json.bak.$(date +%s) # 先备份
|
||||
bash <插件仓库>/scripts/switch.sh aps # 或 upstream,脚本会重写依赖与 bundle 行
|
||||
pnpm install
|
||||
# ⌘Q 重启
|
||||
```
|
||||
|
||||
**回滚验收**:重启后按 [04-部署指南](04-部署指南) §三 的验收清单逐条核对(至少「右列一套侧边栏」
|
||||
与「三个信息层 tab 存在」两条)。
|
||||
|
||||
## 三、发布 / 变更纪律
|
||||
|
||||
1. **版本三元组必须一致**:
|
||||
- `package.json` 的 `version`
|
||||
- `dsh.plugin.json` 的 `version`
|
||||
- `src/client/service.ts` 的 `SIDEBAR_SERVICE_VERSION`
|
||||
三者不一致会被单元测试(`tests/service.spec.ts`)直接拦下——**不要改其中一个而忘了另一个**。
|
||||
2. **门禁三连必过**:`pnpm typecheck && pnpm build && pnpm test`。当前基线:`main` 131 个测试文件 /
|
||||
1367 通过;`sync/v0.24.1` 141 个文件 / 1515 通过(另有 9 个 skip)。
|
||||
3. **产物非字节确定性**:两次构建的字节数可能相同但 **sha 不同**。因此「改完代码 → 让 profile 用旧副本」
|
||||
永远不成立;必须重新 `pnpm build` + 让 profile 重新安装。
|
||||
4. **发布方式**:本 fork 未发布 npm 包,发布 = 推分支/tag + 目标机器 `git fetch && checkout && pnpm build`
|
||||
+ profile 重装。发布前把 `README.md` 与 `docs/wiki/` 一并更新(wiki 见 §六)。
|
||||
5. **不要顺手改无关文件**:re-base 时保持「只带自己的增量」,避免下次升级冲突面扩大。
|
||||
|
||||
## 四、日常巡检清单
|
||||
|
||||
| 频率 | 检查项 | 命令 / 判据 |
|
||||
| --- | --- | --- |
|
||||
| 每次部署后 | 版本三元组一致 | 见 §三.1 |
|
||||
| 每次部署后 | 产物标记齐全 | `grep -c 'apes:agents' lib/client.js`(>0)、`grep -c 'dsh-desktop-content-viewport' lib/client.js`(>0) |
|
||||
| 每次部署后 | profile 副本已刷新 | 比对 `lib/client.js` 与 `~/.dsh/profiles/<p>/node_modules/dsh-apes-sidebar/lib/client.js` 的 sha256 |
|
||||
| 每次升级宿主/插件 | 宿主版本 ↔ peer 范围 | 见 [04-部署指南](04-部署指南) §1.1(不匹配 = 静默禁用) |
|
||||
| 每月/大改前 | 锚点是否已推远端 | `git ls-remote <remote> \| grep -E 'heads/(main\|sync)\|tags/apes'` |
|
||||
| 异常时 | 诊断条与控制台 | 页面左下角红条;控制台 `[dsh-apes-sidebar]` 前缀 |
|
||||
|
||||
## 五、备份与配置铁律
|
||||
|
||||
- **改任何 profile 配置前先备份**,改完**回读验证**(不要假定写入即生效):
|
||||
|
||||
```bash
|
||||
cp ~/.dsh/profiles/<profile>/package.json{,.bak.$(date +%s)}
|
||||
# 改完:
|
||||
python3 -c "import json;json.load(open('$HOME/.dsh/profiles/<profile>/package.json'));print('JSON OK')"
|
||||
```
|
||||
|
||||
- **DSH 应用配置的类型陷阱**:部分字段(如材质/透明度类的 `macosMaterial`)必须是**字符串**
|
||||
(`"off"` / `"transparent"`)。若被写成 YAML 布尔(`false`),DSH 会启动进**恢复模式**。
|
||||
改完务必回读校验类型:
|
||||
|
||||
```bash
|
||||
python3 -c "import yaml;d=yaml.safe_load(open('$HOME/.dsh/settings.yaml'));v=d.get('macosMaterial');print(v, type(v).__name__)"
|
||||
```
|
||||
|
||||
- **不要回滚/手工编辑** profile 的 `pnpm-lock.yaml`:由 pnpm 重建;手工改会导致解析错乱。
|
||||
- **凭据不进仓库**:远端推送用系统凭据助手(`git credential fill`),仓库内不写 token。
|
||||
|
||||
## 六、百科(wiki)本身的维护
|
||||
|
||||
百科源文件版本化在仓库 `docs/wiki/`,通过脚本推送到 Gitea wiki:
|
||||
|
||||
```bash
|
||||
bash scripts/push-wiki.sh # 推送/更新全部页面(幂等:有则 PATCH,无则 POST)
|
||||
bash scripts/push-wiki.sh 05-运维手册 # 只推一页
|
||||
```
|
||||
|
||||
凭据来自 `git credential fill`(与推送代码同一套,不落盘)。修改文档的推荐流程:
|
||||
**改 `docs/wiki/*.md` → 提交到仓库 → 跑脚本推送**,这样 wiki 与代码同源可回溯。
|
||||
|
||||
## 七、诊断与日志
|
||||
|
||||
| 信号 | 含义 | 位置 |
|
||||
| --- | --- | --- |
|
||||
| 左下角红色诊断条 `[dsh-apes-sidebar] <phase> error: …` | client 生命周期某阶段抛错(mount / native / 懒加载等) | 页面左下角(`z-index: 2147483000`),同时 `console.error` 打前缀 |
|
||||
| 控制台 `[dsh-apes-sidebar] …` | 同上,且带上具体阶段名 | 开发者工具 Console |
|
||||
| 「插件像没装」且**无任何日志** | 宿主兼容性预检失败(peer 不匹配)→ **静默禁用** | 只能靠版本矩阵排查,见 [06-故障排查](06-故障排查) §1 |
|
||||
| 界面正常但面板层级错乱 | 面板宿主挂载点/层叠上下文问题 | 见 [02-系统架构](02-系统架构) §四 |
|
||||
|
||||
## 八、性能与容量
|
||||
|
||||
| 项 | 观察值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `lib/client.js` | ≈ 0.9 MB | 主 bundle,始终加载 |
|
||||
| `lib/client-mermaid.js` | ≈ 7 MB(+12 MB map) | 懒加载,仅在需要渲染 Mermaid 时拉取 |
|
||||
| `lib/client-editor.js` | ≈ 2 MB | 懒加载,仅打开编辑器时 |
|
||||
| `lib/client-terminal.js` | ≈ 0.5 MB | 懒加载,仅打开终端时 |
|
||||
| 全量测试 | 约 3–4 分钟 | 变更后按需跑全量,日常可只跑相关 spec |
|
||||
|
||||
主 bundle 不应静态引入上述重型库;新增重型依赖一律走 `src/client/chunks/`(懒加载 chunk)。
|
||||
@@ -0,0 +1,208 @@
|
||||
# 06 · 故障排查
|
||||
|
||||
> 每条都给出**症状 → 根因(机制)→ 处置 → 验证**。标注 ✅ 的条目是在真机上定位并验证过的案例。
|
||||
|
||||
## 速查表
|
||||
|
||||
| 症状 | 最可能根因 | 跳到 |
|
||||
| --- | --- | --- |
|
||||
| 装完「像没装」,界面无变化且**无任何日志** | peer 范围与宿主版本不匹配 → 静默禁用 | §1 |
|
||||
| 页面出现**两个侧边栏** / 两套开合按钮 | 双挂载(两条线同装,或残留挂载行) | §2 |
|
||||
| HTML 预览只显示 `forbidden` | 桌面壳渲染器围栏 + 不透明源 iframe | §3 |
|
||||
| 插件面板盖住宿主/第三方弹窗(如插件市场) | 面板宿主挂在层叠上下文之外 | §4 |
|
||||
| 终端打不开 / `node-pty` 加载失败 | pnpm 拦截原生构建(仅 v0.19.1 线) | §5 |
|
||||
| 改了代码,界面没变 | 产物未重建 / profile 副本未刷新 / 浏览器缓存 | §6 |
|
||||
| DSH 拒绝加载插件、entry pending | 插件声明(`dsh.plugin.json` / bundle patch)不合法 | §7 |
|
||||
| DSH 启动进**恢复模式** | profile 配置被写成错误类型(如 YAML 布尔) | §8 |
|
||||
|
||||
---
|
||||
|
||||
## §1 装完「像没装」:静默禁用 ✅
|
||||
|
||||
**症状**:profile 里依赖装了、文件也在,但界面没有任何变化;日志里也没有插件相关报错。
|
||||
|
||||
**根因**:宿主启动时做兼容性预检,判定 `peerDependencies` 与自身版本是否满足:
|
||||
|
||||
```
|
||||
semver.satisfies(hostVersion, peerRange, { includePrerelease: true })
|
||||
```
|
||||
|
||||
caret 范围**跨 minor 必然为 false**(例如 `^0.1.5-rc.1` 对 `0.2.0`)。判定失败后宿主的处理是
|
||||
**跳过这一行**——不抛错、不打日志、不加载。
|
||||
|
||||
**处置**:核对宿主版本,改用匹配的版本线(见 [04-部署指南](04-部署指南) §1.1)。
|
||||
|
||||
```bash
|
||||
grep -m1 '"version"' "/Applications/DSH Desktop.app/Contents/Resources/app/node_modules/@deepseek-ai/dsh/package.json"
|
||||
grep -n 'dsh-client-ui-sidebar-right' <插件仓库>/package.json # 看 peer 范围
|
||||
```
|
||||
|
||||
**验证**:重启后右列出现插件 tab;`lib/client.js` 里能 grep 到 `apes:agents`。
|
||||
|
||||
**教训**:这一条没有日志可查,因此**部署前先看版本矩阵**是唯一可靠的预防手段。
|
||||
|
||||
## §2 两个侧边栏 / 两套开合按钮 ✅
|
||||
|
||||
**症状**:窗口里出现两套右侧面板与两组开合控件。
|
||||
|
||||
**根因**:两个方向都可能:
|
||||
1. **旧版遗留**:v0.18.0 基线时插件自绘右侧面板与开关(`toggleCluster`)。升级后自绘部分应已移除。
|
||||
2. **双挂载**:同一条线被挂两次(profile 自己的 `cordis.patch.yml` 里还留着旧挂载行,
|
||||
与 bundle patch 同时生效),或者本 fork 线与上游线同时安装(两者都注册 `ctx.betterSidebar`,
|
||||
entry id 冲突,第二个被丢弃)。
|
||||
|
||||
**处置**:
|
||||
|
||||
```bash
|
||||
# 确认产物里已经没有自绘开关
|
||||
grep -c toggleCluster <插件仓库>/lib/client.js # 期望 0
|
||||
|
||||
# 检查是否双挂载:profile 里是否同时存在 bundle 行 与 手写挂载行
|
||||
grep -n "dsh-apes-sidebar\|dsh-better-sidebar" ~/.dsh/profiles/<profile>/cordis.patch.yml ~/.dsh/profiles/<profile>/package.json
|
||||
```
|
||||
|
||||
- 有残留手写挂载行 → 删除它,只保留一条通道。
|
||||
- 两条线同装 → `bash scripts/switch.sh aps`(或 `upstream`)二选一。
|
||||
- 装了聚合包(如 `@linxin666/dsh-web-ui-all`)→ 确认聚合包在本包之前,让包内守卫生效。
|
||||
|
||||
**验证**:右列只剩一套面板与一个开合控件。
|
||||
|
||||
## §3 HTML 预览显示 `forbidden` ✅
|
||||
|
||||
**症状**:预览 HTML 文件时 iframe 里只有一行裸文本 `forbidden`;而「临时解锁」后又能看。
|
||||
|
||||
**根因(三层)**:
|
||||
|
||||
1. DSH Desktop 在**扩展模式**下给所有路由套了一层请求围栏:`ordinaryBrowserEnabled` 恒为 false,
|
||||
不带 Electron 注入的 `x-dsh-desktop-renderer` 头的请求一律 `403 + "forbidden"`;
|
||||
2. 该头只注入给 **mainFrame** 与**同源**子框架;
|
||||
3. 预览 iframe 的 sandbox 令牌不含 `allow-same-origin`,请求时其源为 `null` → 拿不到头 → 403。
|
||||
|
||||
与「是否升级了插件基线」无关(v0.18.0 起行为一致)。
|
||||
|
||||
**处置**:插件已在 1.2.0+ 用 **`srcDoc` 交付**修复(主框架取文档 → 注入 `<base>` → 注入 iframe),
|
||||
`main` 上对应提交 `4c242ac`。**不要**通过给 iframe 加 `allow-same-origin` 来"修"——那是安全降级。
|
||||
|
||||
**验证**:
|
||||
- 沙箱态下自包含 HTML(含内联脚本)能正常渲染;
|
||||
- 有外链资源的 HTML 在沙箱态下缺资源属**已知限制**,点「临时解锁」后应完整显示。
|
||||
|
||||
**排查命令**(确认宿主围栏在起作用):
|
||||
|
||||
```bash
|
||||
lsof -nP -iTCP -sTCP:LISTEN | grep -i dsh # 找宿主端口(每次启动可能不同)
|
||||
curl -s -o /dev/null -w '%{http_code}\n' "http://127.0.0.1:<端口>/" # 无渲染器头 → 403
|
||||
```
|
||||
|
||||
## §4 插件面板盖住宿主/第三方弹窗 ✅
|
||||
|
||||
**症状**:打开「社区插件市场」(宿主自带 `dsh-community-market`)时,弹窗左下角被本插件的
|
||||
文件面板整齐切掉一块。
|
||||
|
||||
**根因**:桌面壳的有框模式会给上游 `#root` 打标 `data-dsh-desktop-content-viewport` 并施加
|
||||
`isolation: isolate`,使 `#root` 成为**层叠上下文**;宿主的 overlay 层(`.dshDesktopOverlay`,
|
||||
`z-index: 1000`)位于其中。插件面板宿主若挂在 `document.body`(该上下文之外)且是定位元素,
|
||||
就会画在整个上下文之上,把 overlay 里的弹窗盖住。
|
||||
|
||||
**处置**:已修(`4422001`)——面板宿主改为挂进 `#root[data-dsh-desktop-content-viewport]`,
|
||||
无标记时回退 `document.body`。**不要**通过调大 `z-index` 解决(25 的取义见
|
||||
[02-系统架构](02-系统架构) §四;乱调会破坏与宿主其他层级的关系)。
|
||||
|
||||
**验证**:插件市场弹窗完整盖住本插件面板,同时本插件面板仍在对话内容之上。
|
||||
|
||||
## §5 终端不可用 / `node-pty` 加载失败(仅 v0.19.1 线)✅
|
||||
|
||||
**症状**:终端面板报 shell 启动失败或提示 `node-pty` 加载失败;`pnpm install` 阶段报
|
||||
"ignored build scripts"。
|
||||
|
||||
**根因**:pnpm 11 默认**拦截依赖的构建脚本**,`node-pty` 的原生二进制没被编译。
|
||||
(`main` 依赖 `node-pty`;`sync/v0.24.1` 已移除该依赖,终端由宿主内置承担。)
|
||||
|
||||
**处置**:
|
||||
|
||||
```bash
|
||||
cd ~/.dsh/profiles/<profile>
|
||||
pnpm approve-builds # 选中 node-pty
|
||||
pnpm rebuild node-pty # 必要时
|
||||
# ⌘Q 完整退出 DSH 再启动
|
||||
```
|
||||
|
||||
**验证**:终端能开出 shell;`node_modules/node-pty/build/Release/*.node` 存在。
|
||||
|
||||
## §6 改了代码,界面没变 ✅
|
||||
|
||||
**症状**:源码已改、`pnpm build` 也跑了,但界面行为仍是旧的。
|
||||
|
||||
**根因(三个叠加因素)**:
|
||||
|
||||
1. 产物**非字节确定性**,且 profile 的 `file:` 依赖是**复制**语义 → profile 里仍是旧副本;
|
||||
2. `lib/client.js` 是浏览器的 `window.__ModuleLoader__.load({...})` bundle,**不是 CJS/ESM**,
|
||||
在 Node 里 `require` 会因 `window is not defined` 报错——因此不能用「在 Node 里试跑」来验证前端改动;
|
||||
3. host 半的改动必须重启进程(client 半有热加载,host 半没有)。
|
||||
|
||||
**处置**:
|
||||
|
||||
```bash
|
||||
cd <插件仓库> && pnpm build
|
||||
cd ~/.dsh/profiles/<profile> && pnpm install # 刷新 file: 副本
|
||||
# ⌘Q 完整退出 DSH → 重启
|
||||
```
|
||||
|
||||
**验证**:比对两边产物 sha:
|
||||
|
||||
```bash
|
||||
shasum -a 256 <插件仓库>/lib/client.js \
|
||||
~/.dsh/profiles/<profile>/node_modules/dsh-apes-sidebar/lib/client.js
|
||||
```
|
||||
|
||||
## §7 DSH 拒绝加载插件 / entry pending
|
||||
|
||||
**症状**:插件目录存在,但宿主不加载,或 loader 报 entry pending / 恢复模式。
|
||||
|
||||
**根因**:插件的声明面不合法或不被接受,常见于:
|
||||
- `dsh.plugin.json` 的 `engines.dsh` 与宿主不符;
|
||||
- bundle patch / 挂载行的 `insert` 结构不合法(例如把声明写成宿主无法解析的形态);
|
||||
- 同 id 重复挂载(`duplicate loader entry id` / `duplicate prefix route`)。
|
||||
|
||||
**处置**:
|
||||
|
||||
```bash
|
||||
cat <插件仓库>/dsh.plugin.json # 检查 id / engines / main
|
||||
cat <插件仓库>/cordis.patch.yml # 检查 insert 结构与守卫表达式
|
||||
grep -n "cordis.patch\|dsh-apes-sidebar" ~/.dsh/profiles/<profile>/package.json
|
||||
```
|
||||
|
||||
确保只有一条挂载通道、`name` 与实际包名一致(本 fork 为 `dsh-apes-sidebar`),
|
||||
`engines.dsh` 与宿主版本相容。改完 `pnpm install` + **完整重启**。
|
||||
|
||||
**验证**:启动日志无 loader 报错;右列出现插件 tab。
|
||||
|
||||
## §8 DSH 启动进「恢复模式」
|
||||
|
||||
**症状**:DSH 启动后进入恢复/安全模式,插件与侧边栏都不正常。
|
||||
|
||||
**根因**:profile 或应用配置被写成**错误类型**。典型是材质/透明度字段要求**字符串**
|
||||
(`"off"` / `"transparent"`),但被 YAML 序列化成了布尔 `false` —— 宿主按类型校验失败即进恢复模式。
|
||||
|
||||
**处置与验证**:
|
||||
|
||||
```bash
|
||||
python3 -c "import yaml;d=yaml.safe_load(open('$HOME/.dsh/settings.yaml'));v=d.get('macosMaterial');print(repr(v), type(v).__name__)"
|
||||
# 期望:'off' str(或 'transparent' str);若为 False bool → 改回字符串
|
||||
```
|
||||
|
||||
改配置前**先备份**,改完**回读校验类型**(见 [05-运维手册](05-运维手册) §五)。
|
||||
|
||||
---
|
||||
|
||||
## 附:定位手法(本次会话验证有效)
|
||||
|
||||
1. **先按用户可见文案 grep 找归属**:`grep -rl "<界面上的中文短语>" <app>/lib <profile>/node_modules`。
|
||||
若两边都搜不到,说明它来自**运行时下载/缓存**(例如市场插件),再按 `NSUserData` 缓存或
|
||||
`desktop-market/state.json` 之类线索去找提供方。
|
||||
2. **再读提供方自己的 CSS/DOM 结构**:拿到它声明的 z-index、定位方式与所在槽位
|
||||
(如 `renderSlot("shell.overlay")`),据此判断是「它太高」还是「我们太高」。
|
||||
3. **层叠问题先找 `isolation` / `transform` / `filter` / `contain`**:这些才是层叠上下文与包含块的真正来源,
|
||||
比 z-index 数值更关键。
|
||||
4. **像素证据要用原图量**:交给视觉模型的截图会被缩放(1920→1280),裁剪坐标 ≠ 屏幕坐标;
|
||||
用 PIL 在原图上量边界、比对 RGB 剖面,再下结论。
|
||||
@@ -0,0 +1,164 @@
|
||||
# 07 · 开发与测试
|
||||
|
||||
> 面向改代码的人(与 agent)。**先读 [02-系统架构](02-系统架构) §六「关键不变量」**,
|
||||
> 再动手改,能省掉大部分回退。
|
||||
|
||||
## 一、工程约定
|
||||
|
||||
| 项 | 约定 |
|
||||
| --- | --- |
|
||||
| 语言 | TypeScript **strict**(含 `noUncheckedIndexedAccess`)、ESM、导入写 `.ts` 扩展名 |
|
||||
| 构建 | `tsdown`(rolldown),host/client 双产物;懒加载 chunk 走 `src/client/chunks/` |
|
||||
| 包管理 | `pnpm`(11.x);`pnpm install` 会触发 `prepare` 构建 |
|
||||
| 测试 | `vitest`;默认 node 环境,需要 DOM 的 spec 用 `// @vitest-environment jsdom` |
|
||||
| 宿主 peer | `dsh-better-sidebar`(本仓库已改名)**永不打包**,必须留在 peer/neverBundle 一侧 |
|
||||
| 命名 | 信息层 tab id 前缀 `apes:`;插件名 `dsh-apes-sidebar` |
|
||||
| 颜色 | 只用 `--dsw-alias-*` 设计令牌(`var(--token, <回退>)`),不写死色值 |
|
||||
|
||||
## 二、目录结构(`main` 实况)
|
||||
|
||||
```
|
||||
src/
|
||||
index.ts HOST 入口:name / inject / apply(ctx, config),路由与围栏装配
|
||||
invariant.ts 零依赖不变量面(host/client 共用)
|
||||
context-types.ts cordis 服务面契约(ctx.sessions / sidebarRight / …)
|
||||
trust-fence.ts 请求信任围栏(Host 头 / 令牌)
|
||||
html-route.ts /sidebar/html 路由(路径围栏 + 体积上限)
|
||||
config.ts 挂载行配置 schema 与默认值(readLimit / mediaLimit / …)
|
||||
fs-*.ts git.ts … 文件系统、Git、会话、任务、终端等 host 能力
|
||||
client/
|
||||
index.tsx CLIENT 入口(apply):装配 store/service/native/apes
|
||||
service.ts ctx.betterSidebar 服务实现(对外 API + 能力清单)
|
||||
state.ts 侧边栏状态存储(store)
|
||||
native/ 原生右侧栏适配(tab 记录、tab 体、tab 芯片标题)
|
||||
builtins/ 内置面板:editor / git / subagent / sidechat / terminal / browser + 预览器
|
||||
sidebar/ 自绘底部工作台与开合控件
|
||||
apes/ 信息层五 tab(本 fork 增量:tabs.tsx / icons.tsx / locales.ts)
|
||||
changes/ diff/ 文件变动与 diff 视图
|
||||
chunks/ 懒加载 chunk:editor / locale / mermaid / terminal
|
||||
locales*.ts i18n(`locales.ts` + 19 个语言覆盖文件)
|
||||
*.module.css 组件样式(含 sidebar.module.css:面板宿主层契约)
|
||||
tests/ vitest specs(131 个文件,含 e2e/ 的挂载脚本型用例)
|
||||
scripts/ install / switch / e2e 挂载 / 发布辅助
|
||||
docs/ external-plugin-guide.md(对外契约)+ plans/(上游历史设计)+ wiki/(本百科源文件)
|
||||
```
|
||||
|
||||
## 三、门禁(提交前必跑)
|
||||
|
||||
```bash
|
||||
pnpm typecheck # tsc --noEmit(strict)
|
||||
pnpm build # rm -rf lib && tsc(d.ts) && tsdown
|
||||
pnpm test # vitest run
|
||||
```
|
||||
|
||||
当前基线(本次会话实测):`main` = 131 文件 / **1367 通过** + 9 skip;
|
||||
`sync/v0.24.1` = 141 文件 / **1515 通过** + 9 skip。全量 test 约 3–4 分钟。
|
||||
**任何一条不过就不要提交**;三条全绿才算完成。
|
||||
|
||||
## 四、测试体系(按层次)
|
||||
|
||||
| 层次 | 环境 | 手法 | 代表 spec |
|
||||
| --- | --- | --- | --- |
|
||||
| 宿主插件形状 / 对外契约 | node | 断言导出面与语义(含与 `package.json` 的版本一致性) | `smoke.spec.ts` · `plugin-shape.spec.ts` · `service.spec.ts` · `consumer-types.ts` |
|
||||
| 原生侧边栏适配 | node | 用「类型为真、运行时为假」的 mock 注册表 | `native-surface.spec.ts` |
|
||||
| 面板宿主与层叠契约 | node + jsdom | CSS 断言 + DOM 挂载点解析 | `panel-host-css.spec.ts` · `panel-host-parent.spec.ts` |
|
||||
| 预览交付 | node + jsdom | 纯逻辑(`<base>` 注入)+ 客户端挂载(stub `fetch`,`createRoot` + `act`) | `html-preview.spec.ts` · `html-preview-frame.spec.tsx` |
|
||||
| 沙箱契约 | node | 对渲染结果做**字符串断言**(服务端渲染把 `srcDoc` 输出为 camelCase `srcDoc=""`,与客户端设置的真实属性 `srcdoc` 不同 —— 断言方式随之不同) | `sandbox-views.spec.tsx` |
|
||||
| 信息层 tab | node | mock `ctx.sessions.list` 与投影 | `apes-tabs.spec.ts` |
|
||||
| 桌面环境探测 | node | stub 全局(URL stamp / preload 标记) | `desktop-env.spec.ts` |
|
||||
|
||||
约定:
|
||||
- **不引入 jsdom 到默认环境**:需要 DOM 的 spec 在文件头写 `// @vitest-environment jsdom`。
|
||||
- **不依赖真实 DSH 宿主**:需要真宿主的用例归到 `tests/e2e/`(脚本型挂载),不进单元门禁。
|
||||
- 新增「契约」必须**同时新增 pin 它的 spec**(例如本文档提到的 25 层级、沙箱令牌、`srcDoc` 交付方式,
|
||||
都有对应断言)。改契约 = 改代码 + 改 spec,两者必须一起提交。
|
||||
|
||||
## 五、扩展点(对外服务)
|
||||
|
||||
其他插件通过 `ctx.betterSidebar` 接入:
|
||||
|
||||
```ts
|
||||
// 注册一个 tab 类型
|
||||
await ctx.betterSidebar.registerTab(descriptor)
|
||||
// 注册一个文件预览器
|
||||
await ctx.betterSidebar.registerFileViewer(viewer)
|
||||
```
|
||||
|
||||
- 服务按**能力清单**做前向兼容门控(能力只增不减),消费方应据此判断可用性而非版本号比较。
|
||||
- 完整契约、字段表与示例见仓库内 [docs/external-plugin-guide.md](../blob/main/docs/external-plugin-guide.md)。
|
||||
- 改服务面时:**只能新增**;删除或改语义需同步更新能力清单、指南与本百科。
|
||||
|
||||
## 六、上游同步:测量式 re-base(本项目的方法论)
|
||||
|
||||
**不 merge 上游**。上游每个版本差距可达数百文件,直接合并会引入无关变更并放大冲突面;
|
||||
本项目改用「先测量、再重建、后补增量」:
|
||||
|
||||
### 步骤
|
||||
|
||||
**① 测出自己的增量(把改名归一化掉)**
|
||||
|
||||
```python
|
||||
# 对每个变更文件:取出两边 blob,把改名归一化后比较
|
||||
old = git('show', f'{base}:{path}'); new = git('show', f'HEAD:{path}')
|
||||
rename_only = old.replace('dsh-better-sidebar', 'dsh-apes-sidebar') == new
|
||||
```
|
||||
→ 得到三张清单:**仅改名**(自动处理)/ **真改动** / **新增**。数量级参考:v0.19.1→v0.24.1 为
|
||||
78 / 11 / 10。
|
||||
|
||||
**② 确定改名的精确范围(不要重新推断)**
|
||||
|
||||
```bash
|
||||
# 仍保留上游名字的文件 = 有意保留(本仓库为 README.md、package.json、docs/plans/*)
|
||||
comm -12 <(git grep -l 'dsh-better-sidebar' <旧基线> | cut -d: -f2 | sort) \
|
||||
<(git grep -l 'dsh-better-sidebar' HEAD | cut -d: -f2 | sort)
|
||||
```
|
||||
其余文件只做子串替换 `dsh-better-sidebar` → `dsh-apes-sidebar`;**camelCase 服务名 `betterSidebar` 必须保留**。
|
||||
|
||||
**③ 造一个"改名归一化基座",让增量与改名解耦**
|
||||
|
||||
```bash
|
||||
git checkout -B tmp/norm-<旧基线> <旧基线 tag>
|
||||
# → 套同一口径改名 → commit
|
||||
git diff tmp/norm-<旧基线> HEAD -- . ':(exclude)pnpm-lock.yaml' > /tmp/apes-delta.patch
|
||||
# 校验:这条 diff 的文件清单必须恰好等于 ① 的「真改动 + 新增」
|
||||
```
|
||||
|
||||
**④ 在新基线上重建 + 三点合并**
|
||||
|
||||
```bash
|
||||
git checkout -B sync/<新基线 tag> <新基线 tag>
|
||||
# → 套同一口径改名 → commit("rename only")
|
||||
git apply -3 /tmp/apes-delta.patch # 冲突会以 <<<<<<< ours / >>>>>>> theirs 留存(命令本身不失败)
|
||||
```
|
||||
|
||||
**⑤ 逐条判读冲突(只有五种形态)**
|
||||
|
||||
| 形态 | 判定 |
|
||||
| --- | --- |
|
||||
| 版本/身份 JSON | 取**新基线**侧,再改成本 fork 的版本;两侧各自新增的元数据取**并集**(依赖、files 条目、`forkedFrom` 块) |
|
||||
| 上游搬走了某个 import | 取上游的新路径,保留本 fork 的新 import |
|
||||
| 上游新环境里的注册块 | 保留本 fork 的块,放进上游的新上下文 |
|
||||
| 同一函数两边都定义(上游上移/下移了它) | 删除本 fork 那份;用 `grep -c 'const <name>'` 断言只剩 1 处 |
|
||||
| 长文档(README) | 整体取上游原文,再机械套上本 fork 的两处增量:顶部 fork notice + 包名替换(**跳过** `github.com/<上游 org>` 与徽章域名行) |
|
||||
|
||||
**⑥ 迁移检查(API 漂移)+ 门禁**
|
||||
|
||||
- 先**解完所有冲突标记再** `pnpm install`(`prepare: tsdown` 会因为语法错误直接失败),
|
||||
并**删掉 lockfile 重新生成**(不要把旧 lockfile 带过来)。
|
||||
- 逐条跑 `pnpm typecheck` 修类型漂移。经验:漂移通常集中在**本 fork 自己新增的模块**里。
|
||||
上游会在 `context-types.ts` 的注释里写清迁移路径(例:0.1.6 的 `subagentsByParent`
|
||||
→ 0.1.7 的 `projectionsBySession[id].values.subagentCatalog`,`list.current` 被删除),照注释做即可。
|
||||
- 最后跑门禁三连,并且**用 `grep` 核对关键标记**(tab id、`srcDoc`、`dsh-desktop-content-viewport` 等)。
|
||||
|
||||
**⑦ 收尾(纪律)**
|
||||
|
||||
- 安全锚点:`backup/pre-sync-<新基线>` 分支 + `apes-v<版本>` tag,**推送远端**。
|
||||
- **不要把新线同步进正在使用的 profile**:peer 范围可能已跨 minor,同步过去只会「静默失效」,
|
||||
同时把本机环境弄脏。保持 profile 在已验证的线上。
|
||||
|
||||
## 七、提交与分支纪律
|
||||
|
||||
- 提交信息用 `type(scope): 摘要`(`feat` / `fix` / `chore` / `docs` / `refactor`),正文说明**为什么**与验证方式。
|
||||
- 一个提交一件事;re-base 类改动拆成「rename only」+「增量移植」两个提交,便于回溯与回滚。
|
||||
- 分支命名:`sync/<上游 tag>`(跟随上游)、`backup/pre-sync-<基线>`(锚点)、其余按需。
|
||||
- 提交前:门禁三连 + 关键标记 grep + (若涉及部署)提醒 profile 重装与完整重启。
|
||||
@@ -0,0 +1,82 @@
|
||||
# apes-sidebar 项目百科
|
||||
|
||||
> DeepSeek Harness(DSH)的**服务化右侧边栏工作台** + **apes 信息层**。
|
||||
> 本项目是 [`dsh-better-sidebar`](https://github.com/omdsh-dev/DSH-better-sidebar)(MIT)的 fork,
|
||||
> 在其「文件 / 文件变动 / 任务 / 侧边对话 / 终端 / 浏览器」工作台之上,追加三个信息层标签页:
|
||||
> **智能体(agents)/ 应用(apps)/ 画像(profile)**。
|
||||
|
||||
## 一、项目速览
|
||||
|
||||
| 项 | 值 |
|
||||
| --- | --- |
|
||||
| 仓库 | `figmar/apes-sidebar`(公开) |
|
||||
| 默认分支 | `main` |
|
||||
| 许可证 | MIT(fork 自 `omdsh-dev/DSH-better-sidebar`) |
|
||||
| 语言 / 构建 | TypeScript(strict)· `tsdown`(rolldown)· `pnpm` |
|
||||
| 宿主 | DSH Desktop(Electron 壳)或 DSH Web profile |
|
||||
| 上游跟随方式 | 不合并上游提交,而是**测量式 re-base**(见 [07-开发与测试](07-开发与测试)) |
|
||||
|
||||
## 二、两条版本线(重要)
|
||||
|
||||
本仓库同时维护两条互相独立、**不能同时挂载**的版本线(两条线都注册 `ctx.betterSidebar`,
|
||||
cordis entry id 冲突,第二个会被丢弃):
|
||||
|
||||
| 分支 | 版本 | 上游基线 | peer / 宿主要求 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **`main`** | 1.2.0 | `dsh-better-sidebar` **v0.19.1**(`8753096`) | `^0.1.5-rc.1`(DSH **0.1.5-rc.1+**) | 已在本机 DSH Desktop 2.0.10(`dsh 0.1.5-rc.2`)实机运行 |
|
||||
| **`sync/v0.24.1`** | 1.3.0 | `dsh-better-sidebar` **v0.24.1**(`a2751cf`) | `^0.2.0-rc.1`(DSH **0.2.0-rc.1+**) | 构建与测试全绿,**待具备 0.2.0 宿主实机验证** |
|
||||
|
||||
> ⚠️ **宿主的兼容性预检是「静默禁用」,不报错、不加载、无日志。**
|
||||
> 宿主用 `semver.satisfies(host, peerRange, { includePrerelease: true })` 判定;caret 范围跨 minor
|
||||
> 不成立(`^0.1.5-rc.1` 在 `0.2.0` 宿主上为 false),于是整行被跳过。
|
||||
> **现象 = 插件"好像没装"**。选线请先看宿主版本,见 [04-部署指南](04-部署指南)。
|
||||
|
||||
## 三、文档地图
|
||||
|
||||
| 页面 | 内容 |
|
||||
| --- | --- |
|
||||
| [01-需求分析](01-需求分析) | **官方原生 vs 装了本包**:官方右列只有「文件树 + 只读文本预览」(附官方包证据),九个 tab 是一个整体需求;7 条需求变化洞察、U1–U6、已知限制 |
|
||||
| [02-系统架构](02-系统架构) | host/client 拆分、三层接口面、面板宿主层级契约、关键不变量 |
|
||||
| [03-功能说明](03-功能说明) | 全部标签页与预览器、信息层数据来源、i18n、主题令牌、安全边界 |
|
||||
| [04-部署指南](04-部署指南) | 环境矩阵、选线、profile 接线、构建、验收清单、卸载 |
|
||||
| [05-运维手册](05-运维手册) | 版本线与分支策略、回滚剧本、发布纪律、巡检、备份铁律 |
|
||||
| [06-故障排查](06-故障排查) | 症状 → 根因 → 处置(含全部实机验证过的案例) |
|
||||
| [07-开发与测试](07-开发与测试) | 工程约定、目录、门禁、测试体系、扩展点、上游 re-base 方法 |
|
||||
|
||||
## 四、快速开始(TL;DR)
|
||||
|
||||
```bash
|
||||
# 1) 构建(Node ≥ 20,pnpm)
|
||||
pnpm install && pnpm typecheck && pnpm build && pnpm test
|
||||
|
||||
# 2) 接到 DSH profile(以桌面 profile 为例,使用 file: 链接依赖)
|
||||
# 在 ~/.dsh/profiles/desktop/package.json 中:
|
||||
# "dsh-apes-sidebar": "file:/绝对路径/dsh-apes-sidebar"
|
||||
# 并在 dsh.profile.bundles 列表里加入 "dsh-apes-sidebar"
|
||||
|
||||
# 3) 让 profile 重新安装依赖,然后 ⌘Q 完整退出 DSH 再启动
|
||||
```
|
||||
|
||||
完整步骤与坑见 [04-部署指南](04-部署指南)。
|
||||
|
||||
## 五、仓库内的事实源
|
||||
|
||||
| 位置 | 说明 |
|
||||
| --- | --- |
|
||||
| `README.md` | 面向用户的功能与安装说明(主体沿用上游文档,顶部有 fork notice) |
|
||||
| `AGENTS.md` | 面向 agent/协作者的工程约定(构建、布局、硬性规则、测试约定)⚠️ 注意:本文件当前描述的是**另一套 from-scratch 实现**,与 `main` 的 fork 树不一致,以源码为准 |
|
||||
| `docs/external-plugin-guide.md` | 插件接入指南:`ctx.betterSidebar.registerTab / registerFileViewer` 的对外契约 |
|
||||
| `docs/plans/` | 上游历史设计文档(保持上游原样,未随 fork 改名) |
|
||||
| `docs/wiki/` | **本百科的源文件**(Gitea wiki 页面的可版本化副本,由 `scripts/push-wiki.sh` 推送) |
|
||||
| `scripts/switch.sh` | 两线切换脚本(`aps` ↔ `upstream`),含 `--status` |
|
||||
|
||||
## 六、谱系说明(如实交代)
|
||||
|
||||
`figmar/apes-sidebar` 这个仓库先后承载过同一产品族的三代实现:
|
||||
|
||||
1. **v1**:早期 JS bundle 版侧边栏插件(仓库描述里提到的「可配置显示名 / 预设 feyOS」即出自那一代);
|
||||
2. **v2**:from-scratch 重写(`src/index.ts` + `src/client/index.tsx` 结构,五个 `apes:*` tab),
|
||||
即 `AGENTS.md` 所描述的那一套,其产物不在当前 `main` 上;
|
||||
3. **当前线(v0.19.1 / v0.24.1 fork)**:即 `main` 与 `sync/v0.24.1` 上的代码——本百科描述的对象。
|
||||
|
||||
若需要把本百科改写成描述 v2 重写版,请告知,源文件在 `docs/wiki/` 可直接替换。
|
||||
+53
-44
@@ -86,6 +86,7 @@
|
||||
"test:mount:aggregate": "bash scripts/e2e-aggregate-mount.sh",
|
||||
"clean": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\"",
|
||||
"check:consumer-types": "bash scripts/check-consumer-types.sh",
|
||||
"verify": "node scripts/check-plugin-api.mjs",
|
||||
"lint": "eslint .",
|
||||
"switch": "bash scripts/switch.sh",
|
||||
"switch:status": "bash scripts/switch.sh --status",
|
||||
@@ -94,21 +95,22 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.2",
|
||||
"@deepseek-ai/dsh-agent": "^0.1.5-rc.1",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.1.5-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-right": "^0.1.5-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.1.5-rc.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
|
||||
"@deepseek-ai/dsh-session": "^0.1.5-rc.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.1.5-rc.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.5-rc.1",
|
||||
"@deepseek-ai/cordis": "^4.0.4",
|
||||
"@deepseek-ai/dsh-agent": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-right": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-invariants": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-session": "^0.2.0-rc.2",
|
||||
"@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"
|
||||
@@ -150,34 +152,38 @@
|
||||
"node-pty": "^1.1.0",
|
||||
"react-icons": "5.7.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"schemastery": "^3.18.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-agent": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-agent-loop": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-attachment": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-locale": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-right": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-agent": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-agent-loop": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-attachment": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-client-locale": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-client-store": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-right": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-code-runtime": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-host-webserver": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-invariants": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-llm": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-scope": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-session": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-session-persistence": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-session-projection": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-settings": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-subagent": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-system-prompt": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-tools": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-user-approval": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-util-time": "0.1.5-rc.2",
|
||||
"@deepseek-ai/dsh-host-webserver": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-invariants": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-llm": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-scope": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-session": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-session-persistence": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-session-projection": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-settings": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-subagent": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-tools": "0.2.0-rc.2",
|
||||
"@deepseek-ai/dsh-user-approval": "0.2.0-rc.2",
|
||||
"@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",
|
||||
"@shikijs/langs": "^4.3.1",
|
||||
@@ -190,6 +196,11 @@
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"anser": "^2.3.5",
|
||||
"cordis": "^4.0.0-rc.8",
|
||||
"diff": "9.0.0",
|
||||
"eslint": "^10.9.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.12.0",
|
||||
"immer": "11.1.21",
|
||||
"jsdom": "^29.1.1",
|
||||
"katex": "^0.16.47",
|
||||
"lightningcss": "^1.32.0",
|
||||
@@ -205,18 +216,16 @@
|
||||
"micromark-util-sanitize-uri": "^2.0.1",
|
||||
"micromark-util-symbol": "^2.0.1",
|
||||
"micromark-util-types": "^2.0.2",
|
||||
"shiki": "^4.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"shiki": "^4.3.1",
|
||||
"simple-icons": "16.35.0",
|
||||
"tsdown": "^0.22.2",
|
||||
"typescript": "^5.6.0",
|
||||
"typescript-eslint": "^8.69.0",
|
||||
"unrun": "^0.2.39",
|
||||
"vitest": "^4.1.8",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"eslint": "^10.9.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.12.0",
|
||||
"typescript-eslint": "^8.69.0"
|
||||
"zustand": "5.0.15"
|
||||
},
|
||||
"forkedFrom": {
|
||||
"name": "dsh-better-sidebar",
|
||||
|
||||
Generated
+1060
-413
File diff suppressed because it is too large
Load Diff
@@ -76,3 +76,4 @@ minimumReleaseAgeExclude:
|
||||
- '@deepseek-ai/dsh-workspace@0.0.1-rc.1'
|
||||
- '@deepseek-ai/schemastery@3.18.1-rc.1 || 3.18.1'
|
||||
- '@huanlin/dsh-plugin-better-locale@0.1.0'
|
||||
- simple-icons@16.35.0
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-plugin-api.mjs — DSH 插件「运行时契约」守卫
|
||||
*
|
||||
* 背景:dsh-apes-sidebar 长期出现「typecheck / build 全绿,运行时仍然报错」的
|
||||
* 假验证问题。根因是本地 devDependencies 与 DSH 实际运行时(App 内 node_modules)
|
||||
* 版本不一致,类型检查校验的是旧 API 契约。
|
||||
*
|
||||
* 本脚本真正比对的是「源码 → DSH 运行时」,而不是「源码 → 本地 node_modules」:
|
||||
* 1) devDeps / peerDeps 与运行时版本漂移
|
||||
* 2) 从 @deepseek-ai/* 导入的值符号是否真实存在于运行时包中(缺失即 React #130 元凶)
|
||||
* 3) plugins 注册的 slot 是否满足宿主声明的 kind 要求(list→id / keyed→key / chain→select)
|
||||
* 4) 是否使用了运行时已移除的 API(如 settings.register)
|
||||
*
|
||||
* 用法:
|
||||
* node check-plugin-api.mjs [repoDir] [--app "/Applications/DSH Desktop.app"]
|
||||
* 退出码:0 = 通过;1 = 发现问题(可接进 CI / build 前置)
|
||||
*/
|
||||
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('--'))
|
||||
const appArgIdx = args.indexOf('--app')
|
||||
const APP = appArgIdx >= 0 && args[appArgIdx + 1]
|
||||
? args[appArgIdx + 1]
|
||||
: '/Applications/DSH Desktop.app'
|
||||
const REPO = repoArg ?? process.cwd()
|
||||
const APP_MODULES = join(APP, 'Contents/Resources/app/node_modules')
|
||||
|
||||
const problems = []
|
||||
const notes = []
|
||||
const fail = (m) => problems.push(m)
|
||||
const note = (m) => notes.push(m)
|
||||
|
||||
const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'))
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (e.name === 'node_modules' || e.name.startsWith('.')) continue
|
||||
const p = join(dir, e.name)
|
||||
if (e.isDirectory()) walk(p, out)
|
||||
else if (/\.(ts|tsx)$/.test(e.name)) out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 运行时包的导出符号集合(解析 ESM 尾部 export { ... })。 */
|
||||
function runtimeExports(pkgName) {
|
||||
const p = join(APP_MODULES, pkgName)
|
||||
if (!existsSync(p)) return null
|
||||
const pkg = readJson(join(p, 'package.json'))
|
||||
const entry = join(p, typeof pkg.exports?.['.']?.default === 'string'
|
||||
? pkg.exports['.'].default : pkg.main ?? 'lib/index.js')
|
||||
if (!existsSync(entry)) return { version: pkg.version, names: new Set(), entry: null }
|
||||
const src = readFileSync(entry, 'utf8')
|
||||
const names = new Set()
|
||||
for (const m of src.matchAll(/export\s*\{([^}]*)\}/g)) {
|
||||
for (const raw of m[1].split(',')) {
|
||||
const n = raw.trim().split(/\s+as\s+/).pop()?.trim()
|
||||
if (n) names.add(n)
|
||||
}
|
||||
}
|
||||
return { version: pkg.version, names, entry }
|
||||
}
|
||||
|
||||
/** 去掉注释,避免把文档里的 API 名字误判为真实调用。 */
|
||||
const stripComments = (s) =>
|
||||
s.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
|
||||
|
||||
// ── 1. 版本漂移:package.json 声明 vs DSH 运行时 ──────────────────────────
|
||||
console.log('■ 1. 依赖版本 vs DSH 运行时')
|
||||
const pkg = readJson(join(REPO, 'package.json'))
|
||||
const versionsUsed = new Set()
|
||||
for (const f of walk(join(REPO, 'src'))) {
|
||||
const s = stripComments(readFileSync(f, 'utf8'))
|
||||
for (const m of s.matchAll(/from\s*['"](@deepseek-ai\/[a-z0-9-]+)['"]/g)) versionsUsed.add(m[1])
|
||||
}
|
||||
const drift = []
|
||||
for (const dep of versionsUsed) {
|
||||
const rt = runtimeExports(dep)
|
||||
if (!rt) { note(` · ${dep}: 运行时未提供(宿主可能未安装该包)`); continue }
|
||||
const dev = pkg.devDependencies?.[dep]
|
||||
const peer = pkg.peerDependencies?.[dep]
|
||||
const mark = dev === undefined ? '· ' : (dev === rt.version ? '✅' : '❌')
|
||||
console.log(` ${mark} ${dep}: devDep=${dev ?? '(无)'} peer=${peer ?? '(无)'} runtime=${rt.version}`)
|
||||
if (dev === undefined) continue
|
||||
if (dev !== rt.version) {
|
||||
drift.push(`${dep}: devDep ${dev} ≠ runtime ${rt.version}`)
|
||||
fail(`devDependencies 精确锁定 ${dep}@${dev},与运行时 ${rt.version} 不一致,typecheck 校验的是旧契约`)
|
||||
}
|
||||
}
|
||||
if (!drift.length) console.log(' ✅ 无漂移')
|
||||
|
||||
// ── 2. 导入的值符号是否存在于运行时 ──────────────────────────────────────
|
||||
console.log('\n■ 2. 源码导入符号 vs 运行时导出')
|
||||
let missingCount = 0
|
||||
for (const f of walk(join(REPO, 'src'))) {
|
||||
const src = stripComments(readFileSync(f, 'utf8'))
|
||||
const re = /import\s+(type\s+)?\{([^}]*)\}\s*from\s*['"](@deepseek-ai\/[a-z0-9-]+)['"]/g
|
||||
for (const m of src.matchAll(re)) {
|
||||
if (m[1]) continue // 整块 type-only import
|
||||
const pkgName = m[3]
|
||||
const rt = runtimeExports(pkgName)
|
||||
if (!rt || rt.names.size === 0) continue
|
||||
for (const raw of m[2].split(',')) {
|
||||
const name = raw.trim().split(/\s+as\s+/)[0]?.trim()
|
||||
if (!name || name.startsWith('type ')) continue // 行内 type 成员
|
||||
if (!rt.names.has(name)) {
|
||||
missingCount++
|
||||
fail(`运行时不存在符号:${name}(${pkgName})← ${relative(REPO, f)} — 渲染时会 e(undefined) → React #130`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!missingCount) console.log(' ✅ 所有值导入在运行时均存在')
|
||||
|
||||
// ── 3. slot 注册是否满足宿主声明的 kind ─────────────────────────────────
|
||||
console.log('\n■ 3. slot 注册 vs 宿主声明的 kind')
|
||||
const hostKinds = new Map()
|
||||
for (const pkg of readdirSync(APP_MODULES).filter(n => n.startsWith('dsh-') || n.startsWith('@'))) {
|
||||
const base = pkg.startsWith('@') ? join(APP_MODULES, pkg) : APP_MODULES
|
||||
const list = pkg.startsWith('@') ? readdirSync(base).filter(n => n.startsWith('dsh-')) : [pkg]
|
||||
for (const name of list) {
|
||||
const dir = join(base, name, 'lib')
|
||||
if (!existsSync(dir)) continue
|
||||
for (const f of readdirSync(dir)) {
|
||||
if (!f.endsWith('.js')) continue
|
||||
const s = readFileSync(join(dir, f), 'utf8')
|
||||
for (const m of s.matchAll(/["']([a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+)["']\s*:\s*\{\s*kind:\s*["'](\w+)["']/g)) {
|
||||
if (!hostKinds.has(m[1])) hostKinds.set(m[1], m[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let slotIssues = 0
|
||||
for (const f of walk(join(REPO, 'src'))) {
|
||||
const src = stripComments(readFileSync(f, 'utf8'))
|
||||
for (const m of src.matchAll(/slots\.register\(\{([\s\S]*?)\}\s*,\s*[A-Za-z_$][\w$]*\s*\)/g)) {
|
||||
const body = m[1]
|
||||
const nameMatch = body.match(/name:\s*["']([^"']+)["']/)
|
||||
if (!nameMatch) continue
|
||||
const name = nameMatch[1]
|
||||
const kind = hostKinds.get(name)
|
||||
if (!kind || kind === 'single') continue
|
||||
const has = (k) => new RegExp(`\\b${k}:`).test(body)
|
||||
const need = { list: 'id', keyed: 'key', chain: 'select' }[kind]
|
||||
if (need && !has(need)) {
|
||||
slotIssues++
|
||||
fail(`slot "${name}" 宿主声明为 ${kind},注册必须带 ${need} ← ${relative(REPO, f)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!slotIssues) console.log(` ✅ 已核对 ${hostKinds.size} 个宿主 slot 声明,无缺参`)
|
||||
|
||||
// ── 4. 已移除 API -------------------------------------------------------
|
||||
// 分两档:removed = 运行时确实不存在,调用即抛错(fail);
|
||||
// deprecated = 运行时仍可用,但官方已挂 Note 要求迁移(note,不阻塞构建)。
|
||||
console.log('\n■ 4. 已移除 / 变更 API')
|
||||
const removed = [
|
||||
{ re: /settings\.register\s*\(/, msg: 'settings.register 已移除,改用 settings.describe / define' },
|
||||
]
|
||||
const deprecated = [
|
||||
{ re: /\.snapshotEvents\s*\(/, msg: 'snapshotEvents 已弃用(运行时仍可用;官方 Note 2026-09-09-deprecate-synchronous-session-event-reads 要求迁异步读)' },
|
||||
{ re: /\.ownEvents\s*\(/, msg: 'ownEvents 已弃用(运行时仍可用,内部转调 snapshotEvents)' },
|
||||
]
|
||||
let removedHits = 0
|
||||
let deprecatedHits = 0
|
||||
for (const f of walk(join(REPO, 'src'))) {
|
||||
const src = stripComments(readFileSync(f, 'utf8'))
|
||||
for (const b of removed) if (b.re.test(src)) { removedHits++; fail(`${b.msg} ← ${relative(REPO, f)}`) }
|
||||
for (const b of deprecated) if (b.re.test(src)) { deprecatedHits++; note(`⚠️ ${b.msg} ← ${relative(REPO, f)}`) }
|
||||
}
|
||||
if (!removedHits) console.log(' ✅ 未发现运行时已移除的 API')
|
||||
if (deprecatedHits) console.log(` ⚠️ ${deprecatedHits} 处使用了已弃用但仍可用的 API(列为备注,不阻塞)`)
|
||||
|
||||
// ── 5. inline 包的运行期依赖闭包 ─────────────────────────────────────────
|
||||
// 背景:vitest 的 `server.deps.inline` 会让 Vite 去解析被 inline 包的全部 import。
|
||||
// 上游 `dsh-client-ui-primitives@0.2.0-rc.2` 把包内 `dependencies` 声明成了空,
|
||||
// 但它的 `lib/index.js` 实际 import 了 23 个外部包 —— 工程未声明的那些在 pnpm
|
||||
// 严格布局下解析不到,会让「间接 import 它的每个 spec 文件」收集失败(0 test)。
|
||||
// 危险点:vitest 此时仍打印 `Tests 1015 passed`,只有 `Test Files 38 failed` 与
|
||||
// 退出码 1 暴露真相 —— 极易被当成「环境噪音」放过。
|
||||
console.log('\n■ 5. inline 包的运行期依赖闭包')
|
||||
const vitestCfg = existsSync(join(REPO, 'vitest.config.ts'))
|
||||
? readFileSync(join(REPO, 'vitest.config.ts'), 'utf8') : ''
|
||||
// 解析 `server.deps.inline`:先还原正则里的 `\/` 转义,再按 package 名 token 提取,
|
||||
// 构造前缀匹配(兼容 `/@scope\/pkg-.*/` 这类写法)。
|
||||
const inlineSource = [...vitestCfg.matchAll(/inline:\s*\[([\s\S]*?)\]/g)]
|
||||
.map(m => m[1].replace(/\\\//g, '/')).join(' ')
|
||||
const inlinePats = [...inlineSource.matchAll(/@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+/g)]
|
||||
.map(m => new RegExp('^' + m[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '.*$'))
|
||||
const NODE_BUILTINS = new Set(['fs', 'path', 'url', 'util', 'os', 'crypto', 'events', 'stream',
|
||||
'assert', 'buffer', 'child_process', 'process', 'module', 'timers', 'zlib', 'http', 'https',
|
||||
'net', 'tty', 'worker_threads', 'perf_hooks', 'readline', 'string_decoder', 'querystring',
|
||||
'v8', 'vm', 'constants', 'punycode', 'async_hooks', 'dns', 'dgram', 'cluster', 'inspector'])
|
||||
const rootOf = (spec) => spec.startsWith('@') ? spec.split('/').slice(0, 2).join('/') : spec.split('/')[0]
|
||||
const isExternalSpec = (spec) => !spec.startsWith('.') && !spec.startsWith('node:') && !NODE_BUILTINS.has(rootOf(spec))
|
||||
const IMPORT_RE = /(?:from\s*|import\s*\(?\s*)['"]([^'"]+)['"]/g
|
||||
const declaredAll = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}), ...(pkg.peerDependencies ?? {}) }
|
||||
|
||||
let inlineChecked = 0
|
||||
let inlineMissing = 0
|
||||
for (const dep of versionsUsed) {
|
||||
if (!inlinePats.some(re => re.test(dep))) continue
|
||||
const dir = join(APP_MODULES, dep)
|
||||
if (!existsSync(dir)) continue
|
||||
const meta = readJson(join(dir, 'package.json'))
|
||||
const entryRel = typeof meta.exports?.['.']?.default === 'string'
|
||||
? meta.exports['.'].default : (meta.main ?? 'lib/index.js')
|
||||
const entry = join(dir, entryRel)
|
||||
if (!existsSync(entry)) continue
|
||||
inlineChecked++
|
||||
const src = readFileSync(entry, 'utf8')
|
||||
const specs = [...src.matchAll(IMPORT_RE)].map(m => m[1]).filter(isExternalSpec)
|
||||
const roots = new Set(specs.map(rootOf))
|
||||
const missingDeps = [...roots].filter(r => !declaredAll[r]).sort()
|
||||
if (missingDeps.length) {
|
||||
inlineMissing++
|
||||
fail(`${dep}@${meta.version} 被 vitest inline,但其运行期依赖有 ${missingDeps.length}/${roots.size} 个未在本工程声明:`
|
||||
+ `\n ${missingDeps.join(', ')}`
|
||||
+ `\n → Vite 解析失败会让每个间接 import 它的 spec 文件收集失败(0 test),`
|
||||
+ `而 vitest 仍会打印 "Tests N passed";以上游包 package.json 的 dependencies 为空时尤其要查`)
|
||||
}
|
||||
if (/sourceMappingURL=/.test(src) && !existsSync(entry + '.map')) {
|
||||
note(`⚠️ ${dep}: 入口带 sourceMappingURL 注释但包内无 .map(上游打包缺陷)→ vitest 报 "trying to read the map file" ENOENT。用 vitest plugin 的 transform 剥掉该注释,不要手改 node_modules(pnpm install 即失效)`)
|
||||
}
|
||||
}
|
||||
if (!inlineChecked) console.log(' ✅ vitest.config 未配置 server.deps.inline(或无匹配包),无需检查')
|
||||
else if (!inlineMissing) console.log(` ✅ 已核对 ${inlineChecked} 个 inline 包的依赖闭包`)
|
||||
|
||||
// ── 结论 ────────────────────────────────────────────────────────────────
|
||||
console.log('\n' + '─'.repeat(60))
|
||||
if (problems.length) {
|
||||
console.log(`❌ 发现 ${problems.length} 个问题:\n`)
|
||||
for (const p of problems) console.log(' · ' + p)
|
||||
if (notes.length) { console.log('\n备注:'); for (const n of notes) console.log(n) }
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('✅ 运行时契约检查通过')
|
||||
if (notes.length) { console.log('\n备注:'); for (const n of notes) console.log(n) }
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# push-wiki.sh — 把 docs/wiki/*.md 推送/更新到本仓库的 Gitea wiki
|
||||
#
|
||||
# 为什么需要它:本项目的百科源文件**版本化在仓库里**(docs/wiki/),
|
||||
# Gitea wiki 只是它的发布面。改文档 = 改仓库文件 + 跑本脚本,日志与回溯都在 git 里。
|
||||
#
|
||||
# 行为:
|
||||
# · 幂等:页面不存在则 POST 新建,已存在则 PATCH 覆盖(标题 = 文件名去掉 .md)
|
||||
# · 凭据来自 `git credential fill`(与推代码同一套),脚本自身不存任何 token
|
||||
# · 凭据经临时 netrc 文件传给 curl(600 权限,退出即删),不进入 argv
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/push-wiki.sh # 推送全部页面
|
||||
# bash scripts/push-wiki.sh 05-运维手册 # 只推指定页面(按文件名/片段匹配)
|
||||
# WIKI_REMOTE=origin bash scripts/push-wiki.sh # 指定 remote(默认 gitea,缺失则 origin)
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
WIKI_DIR="$ROOT/docs/wiki"
|
||||
FILTER="${1:-}"
|
||||
|
||||
# ---- 解析 remote / owner / repo -------------------------------------------
|
||||
pick_remote() {
|
||||
for r in "${WIKI_REMOTE:-}" gitea origin; do
|
||||
[ -z "$r" ] && continue
|
||||
if git remote get-url "$r" >/dev/null 2>&1; then echo "$r"; return 0; fi
|
||||
done
|
||||
echo "无法找到可用的 git remote(试过: ${WIKI_REMOTE:-<空>} gitea origin)" >&2
|
||||
return 1
|
||||
}
|
||||
REMOTE="$(pick_remote)"
|
||||
URL="$(git remote get-url "$REMOTE")"
|
||||
case "$URL" in
|
||||
https://*) ;;
|
||||
*) echo "本脚本只支持 https remote(当前: $URL)" >&2; exit 1 ;;
|
||||
esac
|
||||
HOST="$(printf '%s' "$URL" | sed -E 's#^https://([^/]+)/.*#\1#')"
|
||||
SLUG="$(printf '%s' "$URL" | sed -E 's#^https://[^/]+/##; s#\.git$##')"
|
||||
OWNER="${SLUG%%/*}"
|
||||
REPO="${SLUG#*/}"
|
||||
API="https://$HOST/api/v1/repos/$OWNER/$REPO/wiki"
|
||||
|
||||
echo "wiki 目标 : $OWNER/$REPO @ $HOST (remote: $REMOTE)"
|
||||
echo "源目录 : docs/wiki"
|
||||
echo
|
||||
|
||||
# ---- 凭据 -----------------------------------------------------------------
|
||||
CRED="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill 2>/dev/null || true)"
|
||||
USER="$(printf '%s\n' "$CRED" | sed -n 's/^username=//p')"
|
||||
PASS="$(printf '%s\n' "$CRED" | sed -n 's/^password=//p')"
|
||||
if [ -z "$PASS" ]; then
|
||||
echo "未从 git credential helper 取到 $HOST 的凭据(先确保 git push 能成功)" >&2
|
||||
exit 1
|
||||
fi
|
||||
NETRC="$(mktemp)"
|
||||
chmod 600 "$NETRC"
|
||||
trap 'rm -f "$NETRC"' EXIT
|
||||
printf 'machine %s\nlogin %s\npassword %s\n' "$HOST" "$USER" "$PASS" > "$NETRC"
|
||||
|
||||
json_body() { # $1 = file
|
||||
python3 - "$1" <<'PY'
|
||||
import base64, json, sys, pathlib
|
||||
p = pathlib.Path(sys.argv[1])
|
||||
print(json.dumps({
|
||||
"title": p.stem,
|
||||
"content_base64": base64.b64encode(p.read_text(encoding="utf-8").encode("utf-8")).decode("ascii"),
|
||||
}, ensure_ascii=False))
|
||||
PY
|
||||
}
|
||||
|
||||
api() { # $1 = method, $2 = path, $3 = body(optional) -> 输出 HTTP 码
|
||||
local method="$1" path="$2" body="${3:-}"
|
||||
if [ -n "$body" ]; then
|
||||
curl -s --netrc-file "$NETRC" -o /tmp/push-wiki-resp.json -w '%{http_code}' \
|
||||
-X "$method" -H 'Content-Type: application/json' --data-binary "$body" "$API$path"
|
||||
else
|
||||
curl -s --netrc-file "$NETRC" -o /tmp/push-wiki-resp.json -w '%{http_code}' -X "$method" "$API$path"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- 逐页推送 -------------------------------------------------------------
|
||||
shopt -s nullglob
|
||||
files=("$WIKI_DIR"/*.md)
|
||||
[ ${#files[@]} -gt 0 ] || { echo "docs/wiki 下没有 .md 文件" >&2; exit 1; }
|
||||
|
||||
ok=0; fail=0
|
||||
for f in "${files[@]}"; do
|
||||
title="$(basename "$f" .md)"
|
||||
if [ -n "$FILTER" ] && [[ "$title" != *"$FILTER"* ]]; then continue; fi
|
||||
body="$(json_body "$f")"
|
||||
|
||||
code="$(api POST /new "$body")"
|
||||
if [ "$code" = "201" ]; then
|
||||
echo " + $title (新建)"; ok=$((ok+1)); continue
|
||||
fi
|
||||
enc="$(python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1], safe=""))' "$title")"
|
||||
code="$(api PATCH "/page/$enc" "$body")"
|
||||
if [ "$code" = "200" ]; then
|
||||
echo " ~ $title (更新)"; ok=$((ok+1))
|
||||
else
|
||||
echo " ! $title 失败(HTTP $code): $(head -c 200 /tmp/push-wiki-resp.json 2>/dev/null)" >&2
|
||||
fail=$((fail+1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "完成:成功 $ok 页,失败 $fail 页"
|
||||
[ "$fail" -eq 0 ] || exit 1
|
||||
echo "查看: https://$HOST/$OWNER/$REPO/wiki"
|
||||
+10
-10
@@ -18,11 +18,11 @@
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
IconChevronLeftOutline14,
|
||||
IconChevronRightOutline14,
|
||||
IconLinkOutline14,
|
||||
IconRefreshOutline14,
|
||||
IconWarningOutline16,
|
||||
IconChevronLeftOutlineRegular,
|
||||
IconChevronRightOutlineRegular,
|
||||
IconLinkOutlineRegular,
|
||||
IconRefreshOutlineRegular,
|
||||
IconWarningOutlineMedium,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { VscLinkExternal } from 'react-icons/vsc'
|
||||
import { api } from './api.ts'
|
||||
@@ -172,7 +172,7 @@ export function BrowserView(props: TabComponentProps) {
|
||||
disabled={cursor <= 0}
|
||||
onClick={goBack}
|
||||
>
|
||||
<IconChevronLeftOutline14 />
|
||||
<IconChevronLeftOutlineRegular />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -182,7 +182,7 @@ export function BrowserView(props: TabComponentProps) {
|
||||
disabled={cursor >= history.length - 1}
|
||||
onClick={goForward}
|
||||
>
|
||||
<IconChevronRightOutline14 />
|
||||
<IconChevronRightOutlineRegular />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -191,7 +191,7 @@ export function BrowserView(props: TabComponentProps) {
|
||||
title={t('refresh')}
|
||||
onClick={() => { setReloadKey(key => key + 1) }}
|
||||
>
|
||||
<IconRefreshOutline14 />
|
||||
<IconRefreshOutlineRegular />
|
||||
</button>
|
||||
<input
|
||||
className={css.browserInput}
|
||||
@@ -210,7 +210,7 @@ export function BrowserView(props: TabComponentProps) {
|
||||
title={t('browserGo')}
|
||||
onClick={() => { navigateTo(input) }}
|
||||
>
|
||||
<IconLinkOutline14 />
|
||||
<IconLinkOutlineRegular />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -273,7 +273,7 @@ export function BrowserEmbedBlocked(props: {
|
||||
try { host = new URL(url).hostname } catch { /* keep the raw URL */ }
|
||||
return (
|
||||
<div className={css.browserBlocked}>
|
||||
<IconWarningOutline16 size={16} />
|
||||
<IconWarningOutlineMedium size={16} />
|
||||
<div className={css.browserBlockedTitle}>{t('browserEmbedBlocked', { host })}</div>
|
||||
<div className={css.browserBlockedDesc}>{t('browserEmbedBlockedDesc')}</div>
|
||||
<div className={css.browserBlockedActions}>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* renderer — the same one the changes tab's inline preview uses.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconRefreshOutlineMedium } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import { api } from './api.ts'
|
||||
import type { SidebarDiffRef } from './state.ts'
|
||||
@@ -170,7 +170,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
|
||||
title={t('refresh')}
|
||||
onClick={refresh}
|
||||
>
|
||||
<IconRefreshOutline16 size={14} />
|
||||
<IconRefreshOutlineMedium size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { createElement } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCheckOutline16, IconFolderOpen16, IconRefreshOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconCheckOutlineMedium, IconFolderOpenMedium, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { api, isOutsideWorkspaceMessage, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { BinaryDownload } from './binary-download.tsx'
|
||||
@@ -447,7 +447,7 @@ export function EditorHost(props: {
|
||||
title={`${t('save')} (Ctrl/Cmd+S)`}
|
||||
onClick={() => { controlsRef.current?.save() }}
|
||||
>
|
||||
<IconCheckOutline16 size={14} />
|
||||
<IconCheckOutlineMedium size={14} />
|
||||
</button>
|
||||
)}
|
||||
{saveLabel !== '' && (
|
||||
@@ -461,7 +461,7 @@ export function EditorHost(props: {
|
||||
title={t('refresh')}
|
||||
onClick={refreshFile}
|
||||
>
|
||||
<IconRefreshOutline14 size={14} />
|
||||
<IconRefreshOutlineRegular size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -472,7 +472,7 @@ export function EditorHost(props: {
|
||||
aria-pressed={treeOpen}
|
||||
onClick={toggleTree}
|
||||
>
|
||||
<IconFolderOpen16 size={14} />
|
||||
<IconFolderOpenMedium size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.editorBody}>
|
||||
@@ -483,7 +483,7 @@ export function EditorHost(props: {
|
||||
? <FenceErrorNotice store={store} onDisabled={() => { setReloadSeq(sequence => sequence + 1) }} />
|
||||
: <div className={css.editorError}>{load.message}</div>)}
|
||||
{!showEmpty && load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
|
||||
{!showEmpty && load.status === 'ready' && createElement(load.viewer.component, {
|
||||
{!showEmpty && load.status === 'ready' && load.viewer.component !== undefined && createElement(load.viewer.component, {
|
||||
ctx, store, scope, path, title,
|
||||
viewerId: load.viewer.id,
|
||||
content: load.content,
|
||||
|
||||
+13
-13
@@ -24,8 +24,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
Button, IconChevronRightOutline14, IconCloseFill14, IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16,
|
||||
IconEditOutline16, IconLinkOutline16, IconTrashOutline16, Menu, Modal, type MenuEntry, type MenuItem, writeClipboard,
|
||||
Button, IconChevronRightOutlineRegular, IconCloseFillRegular, IconCodeOutlineMedium, IconCopyOutlineMedium, IconDownloadOutlineMedium,
|
||||
IconEditOutlineMedium, IconLinkOutlineMedium, IconTrashOutlineMedium, Menu, Modal, type MenuEntry, type MenuItem, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { SiCursor, SiZedindustries } from 'react-icons/si'
|
||||
import { VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
|
||||
@@ -486,7 +486,7 @@ export function FileTree(props: {
|
||||
if (target.id === 'vscode') return <IconVscode16 size={14} />
|
||||
if (target.id === 'cursor') return <SiCursor size={14} />
|
||||
if (target.id === 'zed') return <SiZedindustries size={14} />
|
||||
return <IconCodeOutline16 size={14} />
|
||||
return <IconCodeOutlineMedium size={14} />
|
||||
}
|
||||
const pinned = openWithTargets
|
||||
.filter(target => pinnedIds.includes(target.id))
|
||||
@@ -537,7 +537,7 @@ export function FileTree(props: {
|
||||
label: (
|
||||
<span className={css.openWithLabel}>
|
||||
<span className={css.openWithName}>{t('openWithMenu')}</span>
|
||||
<IconChevronRightOutline14 size={14} className={css.openWithChevron} aria-hidden />
|
||||
<IconChevronRightOutlineRegular size={14} className={css.openWithChevron} aria-hidden />
|
||||
</span>
|
||||
),
|
||||
icon: <VscLinkExternal size={14} />,
|
||||
@@ -661,7 +661,7 @@ export function FileTree(props: {
|
||||
>
|
||||
{dirRowIcon(entry.path, isOpen)}
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
||||
{entry.isSymlink && <IconLinkOutlineMedium size={12} className={css.explorerSymlink} />}
|
||||
{rowActions(entry)}
|
||||
</div>
|
||||
{isOpen && renderLevel(entry.path, depth + 1)}
|
||||
@@ -694,7 +694,7 @@ export function FileTree(props: {
|
||||
>
|
||||
{fileRowIcon(entry.path)}
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
||||
{entry.isSymlink && <IconLinkOutlineMedium size={12} className={css.explorerSymlink} />}
|
||||
{rowActions(entry)}
|
||||
</div>
|
||||
)
|
||||
@@ -726,7 +726,7 @@ export function FileTree(props: {
|
||||
aria-label={t('dismiss')}
|
||||
onClick={() => { setActionError(null) }}
|
||||
>
|
||||
<IconCloseFill14 />
|
||||
<IconCloseFillRegular />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -829,7 +829,7 @@ export function FileTree(props: {
|
||||
items={[
|
||||
// The open escapes head the FILE menu (dirs only get copy).
|
||||
...(rowMenu?.isDir === false && onOpenFileNewTab !== undefined
|
||||
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={14} /> }]
|
||||
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutlineMedium size={14} /> }]
|
||||
: []),
|
||||
...(rowMenu?.isDir === false && onOpenFileSide !== undefined
|
||||
? [{ id: 'open-side', label: t('openFileSide'), icon: <VscFolderOpened size={14} /> }]
|
||||
@@ -837,21 +837,21 @@ export function FileTree(props: {
|
||||
...openWithEntries(),
|
||||
// Download applies to files only (the host route refuses directories).
|
||||
...(rowMenu?.isDir === false
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutlineMedium size={14} /> }]
|
||||
: []),
|
||||
// Upload into a directory (incl. the workspace root row).
|
||||
...(rowMenu?.isDir === true
|
||||
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={14} /> }]
|
||||
: []),
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutlineMedium size={14} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutlineMedium size={14} /> },
|
||||
// Explorer mutations close the menu; the workspace ROOT row is the
|
||||
// session itself — never renamable or deletable (server double-guards).
|
||||
...(rowMenu !== null && rowMenu.path !== cwd
|
||||
? [
|
||||
{ id: 'mutate-sep', type: 'separator' } as MenuEntry,
|
||||
{ id: 'rename', label: t('rename'), icon: <IconEditOutline16 size={14} /> },
|
||||
{ id: 'delete', label: t('delete'), icon: <IconTrashOutline16 size={14} />, danger: true },
|
||||
{ id: 'rename', label: t('rename'), icon: <IconEditOutlineMedium size={14} /> },
|
||||
{ id: 'delete', label: t('delete'), icon: <IconTrashOutlineMedium size={14} />, danger: true },
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
|
||||
@@ -42,9 +42,9 @@
|
||||
*/
|
||||
import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
IconChevronDownOutline14,
|
||||
IconPlusOutline16,
|
||||
IconSettingsOutline16,
|
||||
IconChevronDownOutlineRegular,
|
||||
IconPlusOutlineMedium,
|
||||
IconSettingsOutlineMedium,
|
||||
Input,
|
||||
Menu,
|
||||
Modal,
|
||||
@@ -420,7 +420,7 @@ function SelectMenu(props: {
|
||||
<span className={css.selectAnchorText}>
|
||||
{selected.length === 0 ? (placeholder ?? '—') : selected.map(option => textOf(option.title)).join(', ')}
|
||||
</span>
|
||||
<IconChevronDownOutline14 size={12} />
|
||||
<IconChevronDownOutlineRegular size={12} />
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -817,7 +817,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
aria-label={`${props.title} ${t('settingsPopup')}`}
|
||||
onClick={props.onOpenSettings}
|
||||
>
|
||||
<IconSettingsOutline16 size={12} />
|
||||
<IconSettingsOutlineMedium size={12} />
|
||||
<span>{t('settingsPopup')}</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -893,7 +893,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
title={t('settingsPopup')}
|
||||
onClick={() => { setStripSettingsOpen(true) }}
|
||||
>
|
||||
<IconSettingsOutline16 size={14} />
|
||||
<IconSettingsOutlineMedium size={14} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
@@ -935,7 +935,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
>
|
||||
<span className={css.cardTop}>
|
||||
<span className={css.cardIconChip}>
|
||||
<IconPlusOutline16 size={16} />
|
||||
<IconPlusOutlineMedium size={16} />
|
||||
</span>
|
||||
<span className={css.cardTitle}>{t('addPluginsTabCard')}</span>
|
||||
</span>
|
||||
@@ -974,7 +974,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
>
|
||||
<span className={css.cardTop}>
|
||||
<span className={css.cardIconChip}>
|
||||
<IconPlusOutline16 size={16} />
|
||||
<IconPlusOutlineMedium size={16} />
|
||||
</span>
|
||||
<span className={css.cardTitle}>{t('addPluginsViewerCard')}</span>
|
||||
</span>
|
||||
|
||||
+35
-23
@@ -29,16 +29,16 @@ import clsx from 'clsx'
|
||||
import {
|
||||
ConnectionIndicator,
|
||||
DiffBlock,
|
||||
IconApiOutline14,
|
||||
IconBrowseOutline16,
|
||||
IconChevronRightOutline14,
|
||||
IconEditOutline16,
|
||||
IconNewChatOutline16,
|
||||
IconPlusOutline16,
|
||||
IconSearchOutline16,
|
||||
IconSendOutline16,
|
||||
IconSparkle16,
|
||||
IconStopFill16,
|
||||
IconApiOutlineRegular,
|
||||
IconBrowseOutlineMedium,
|
||||
IconChevronRightOutlineRegular,
|
||||
IconEditOutlineMedium,
|
||||
IconNewChatOutlineMedium,
|
||||
IconPlusOutlineMedium,
|
||||
IconSearchOutlineMedium,
|
||||
IconSendOutlineMedium,
|
||||
IconSparkleMedium,
|
||||
IconStopFillMedium,
|
||||
MarkdownText,
|
||||
Menu,
|
||||
ReadBlock,
|
||||
@@ -198,7 +198,7 @@ function CollapsibleRow(props: {
|
||||
)}
|
||||
>
|
||||
<span className={css.sidechatRowChevron}>
|
||||
<IconChevronRightOutline14 size={12} />
|
||||
<IconChevronRightOutlineRegular size={12} />
|
||||
</span>
|
||||
{leading}
|
||||
{label}
|
||||
@@ -239,19 +239,19 @@ function toolLeading(name: string, failed: boolean): React.ReactNode {
|
||||
switch (name) {
|
||||
case 'bash':
|
||||
case 'pwsh':
|
||||
return <IconApiOutline14 size={14} />
|
||||
return <IconApiOutlineRegular size={14} />
|
||||
case 'read':
|
||||
case 'web_fetch':
|
||||
return <IconBrowseOutline16 size={14} />
|
||||
return <IconBrowseOutlineMedium size={14} />
|
||||
case 'edit':
|
||||
case 'write':
|
||||
return <IconEditOutline16 size={14} />
|
||||
return <IconEditOutlineMedium size={14} />
|
||||
case 'grep':
|
||||
case 'glob':
|
||||
case 'web_search':
|
||||
return <IconSearchOutline16 size={14} />
|
||||
return <IconSearchOutlineMedium size={14} />
|
||||
default:
|
||||
return <IconSparkle16 size={14} />
|
||||
return <IconSparkleMedium size={14} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,13 +354,26 @@ export function SideChatView(props: {
|
||||
...shared,
|
||||
signal: (signal: string) => t('sideChatBlockSignal', { signal }),
|
||||
exitCode: (exitCode: number) => t('sideChatBlockExitCode', { code: exitCode }),
|
||||
noExitCode: t('sideChatBlockNoExitCode'),
|
||||
running: t('sideChatBlockRunning'),
|
||||
failed: t('sideChatBlockFailed'),
|
||||
done: t('sideChatBlockDone'),
|
||||
noOutput: t('sideChatBlockNoOutput'),
|
||||
},
|
||||
diff: { ...shared, files: (count: number) => t('sideChatBlockFiles', { count }) },
|
||||
read: { ...shared, window: (shown: number, total: number) => t('sideChatBlockWindow', { shown, total }) },
|
||||
diff: {
|
||||
...shared,
|
||||
codeLabel: t('codeLabel'),
|
||||
wrapLabel: t('wrapLabel'),
|
||||
unwrapLabel: t('unwrapLabel'),
|
||||
files: (count: number) => t('sideChatBlockFiles', { count }),
|
||||
},
|
||||
read: {
|
||||
...shared,
|
||||
codeLabel: t('codeLabel'),
|
||||
wrapLabel: t('wrapLabel'),
|
||||
unwrapLabel: t('unwrapLabel'),
|
||||
window: (shown: number, total: number) => t('sideChatBlockWindow', { shown, total }),
|
||||
},
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -593,7 +606,7 @@ export function SideChatView(props: {
|
||||
|
||||
const menuItems = useMemo<MenuEntry[]>(() => {
|
||||
const items: MenuEntry[] = [
|
||||
{ id: '$new', label: t('sideChatNew'), icon: <IconPlusOutline16 /> },
|
||||
{ id: '$new', label: t('sideChatNew'), icon: <IconPlusOutlineMedium /> },
|
||||
]
|
||||
if (threads.length > 0) {
|
||||
items.push({ type: 'separator', id: '$sep' })
|
||||
@@ -674,7 +687,7 @@ export function SideChatView(props: {
|
||||
return (
|
||||
<div className={css.sidechat}>
|
||||
<div className={css.sidechatHero}>
|
||||
<IconNewChatOutline16 />
|
||||
<IconNewChatOutlineMedium />
|
||||
<div
|
||||
className={clsx(
|
||||
css.sidechatHeroTitle,
|
||||
@@ -739,7 +752,6 @@ export function SideChatView(props: {
|
||||
<ConnectionIndicator
|
||||
state={connectionState}
|
||||
disconnectedLabel={t('sideChatConnDisconnected')}
|
||||
reconnectLabel={t('sideChatConnReconnect')}
|
||||
connectingLabel={t('sideChatConnConnecting')}
|
||||
recoveredLabel={t('sideChatConnRecovered')}
|
||||
reconnectActionLabel={t('sideChatConnReconnectAction')}
|
||||
@@ -791,7 +803,7 @@ export function SideChatView(props: {
|
||||
disabled={busy !== null}
|
||||
title={t('sideChatCancelTitle')}
|
||||
>
|
||||
<IconStopFill16 />
|
||||
<IconStopFillMedium />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
@@ -802,7 +814,7 @@ export function SideChatView(props: {
|
||||
disabled={composer.trim() === '' || busy !== null}
|
||||
title={t('sideChatSend')}
|
||||
>
|
||||
<IconSendOutline16 />
|
||||
<IconSendOutlineMedium />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type ReactNode } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconCloseFillRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { referenceInChat as referenceInChatShared } from './reference-in-chat.ts'
|
||||
import {
|
||||
@@ -784,7 +784,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
aria-label={t('collapseBottomPanel')}
|
||||
onClick={() => { store.reduce(toggleBottomPanel) }}
|
||||
>
|
||||
<IconCloseFill14 />
|
||||
<IconCloseFillRegular />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className={css.panelBody}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+200
-9
@@ -23,7 +23,9 @@ import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent }
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconRefreshOutline14, 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: '
|
||||
@@ -250,7 +322,7 @@ function CatalogRows({
|
||||
className={css.subagentErrorRetry}
|
||||
onClick={() => { refresh(parentSessionId) }}
|
||||
>
|
||||
<IconRefreshOutline14 />
|
||||
<IconRefreshOutlineRegular />
|
||||
{t('retry')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -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
|
||||
@@ -789,7 +958,7 @@ export function SubagentView(props: {
|
||||
disabled={rootId === undefined}
|
||||
onClick={() => { if (rootId !== undefined) refresh(rootId) }}
|
||||
>
|
||||
<IconRefreshOutline14 />
|
||||
<IconRefreshOutlineRegular />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconCloseFill14, IconPlusOutline16, Menu,
|
||||
IconCloseFillRegular, IconPlusOutlineMedium, Menu,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SidebarTab } from './state.ts'
|
||||
import { isAgentTabId } from './state.ts'
|
||||
@@ -241,7 +241,7 @@ export function TabBar(props: {
|
||||
onClose(tab.id)
|
||||
}}
|
||||
>
|
||||
<IconCloseFill14 />
|
||||
<IconCloseFillRegular />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@@ -275,7 +275,7 @@ export function TabBar(props: {
|
||||
title={t('newTab')}
|
||||
onClick={() => { setMenuOpen(v => !v); setTabMenu(null) }}
|
||||
>
|
||||
<IconPlusOutline16 />
|
||||
<IconPlusOutlineMedium />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -19,7 +19,7 @@ import clsx from 'clsx'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconCheckOutlineMedium, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { markdownTextProps } from './markdown-labels.tsx'
|
||||
import { api, htmlUrl } from './api.ts'
|
||||
import { markdownPreviewSource } from './markdown-frontmatter.ts'
|
||||
@@ -462,7 +462,7 @@ export function TextEditor(props: FileViewerProps) {
|
||||
title={`${t('save')} (Ctrl/Cmd+S)`}
|
||||
onClick={save}
|
||||
>
|
||||
<IconCheckOutline16 />
|
||||
<IconCheckOutlineMedium />
|
||||
</button>
|
||||
)}
|
||||
{saveLabel !== '' && <span className={clsx(css.editorStatus, saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
import { useEffect, useRef, useState, type InputHTMLAttributes } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconFolderOpen16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconFolderOpenMedium, IconRefreshOutlineMedium } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api } from './api.ts'
|
||||
import type { BetterSidebarService } from './service.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
@@ -195,7 +195,7 @@ export function TreePanel(props: {
|
||||
title={t('refresh')}
|
||||
onClick={() => { setRefreshTick(tick => tick + 1) }}
|
||||
>
|
||||
<IconRefreshOutline16 size={14} />
|
||||
<IconRefreshOutlineMedium size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -215,7 +215,7 @@ export function TreePanel(props: {
|
||||
disabled={busy}
|
||||
onClick={() => { folderInputRef.current?.click() }}
|
||||
>
|
||||
<IconFolderOpen16 size={14} />
|
||||
<IconFolderOpenMedium size={14} />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* `browser:<n>` the same way (no quota). The editor IS the files window
|
||||
* (the old standalone explorer merged into it).
|
||||
*/
|
||||
import { IconCodeOutline16, IconPanelLeftOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconCodeOutlineMedium, IconPanelLeftOutlineMedium } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import {
|
||||
browserTabIcon, changesTabIcon, filesTabIcon, sidechatTabIcon, tasksTabIcon, terminalTabIcon,
|
||||
@@ -109,13 +109,13 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
options: [
|
||||
{
|
||||
value: true,
|
||||
icon: (size: number) => <IconPanelLeftOutline16 size={size} />,
|
||||
icon: (size: number) => <IconPanelLeftOutlineMedium size={size} />,
|
||||
title: () => t('editorExplorerMerged'),
|
||||
desc: () => t('editorExplorerMergedDesc'),
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
icon: (size: number) => <IconCodeOutline16 size={size} />,
|
||||
icon: (size: number) => <IconCodeOutlineMedium size={size} />,
|
||||
title: () => t('editorExplorerSplit'),
|
||||
desc: () => t('editorExplorerSplitDesc'),
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* and `icon` — so the Side card settings page can render the enable/disable
|
||||
* inventory without hardcoding (eating our own dogfood).
|
||||
*/
|
||||
import { IconCodeOutline16, IconDownloadOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconCodeOutlineMedium, IconDownloadOutlineMedium } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { lazyChunkComponent } from '../lazy-chunk.tsx'
|
||||
import { PdfView } from '../PdfView.tsx'
|
||||
import { BinaryDownload } from '../binary-download.tsx'
|
||||
@@ -103,7 +103,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
{
|
||||
id: 'code',
|
||||
title: () => t('viewerCode'),
|
||||
icon: (size: number) => <IconCodeOutline16 size={size} />,
|
||||
icon: (size: number) => <IconCodeOutlineMedium size={size} />,
|
||||
exts: [],
|
||||
priority: -100,
|
||||
fetchStrategy: 'fsRead',
|
||||
@@ -112,7 +112,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
{
|
||||
id: 'binary-download',
|
||||
title: () => t('viewerBinary'),
|
||||
icon: (size: number) => <IconDownloadOutline16 size={size} />,
|
||||
icon: (size: number) => <IconDownloadOutlineMedium size={size} />,
|
||||
exts: ['doc', 'xls', 'ppt'],
|
||||
priority: -50,
|
||||
fetchStrategy: 'binary-download',
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* diff tab via the shell.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { IconCloseOutline16, IconRefreshOutline16, IconRightUpOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconCloseOutlineMedium, IconRefreshOutlineMedium, IconRightUpOutlineMedium, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SessionScope } from '../api.ts'
|
||||
import { api, htmlUrl } from '../api.ts'
|
||||
import { t } from '../locales.ts'
|
||||
@@ -491,7 +491,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
|
||||
disabled={loading}
|
||||
onClick={() => { setTick(value => value + 1) }}
|
||||
>
|
||||
<IconRefreshOutline16 size={14} />
|
||||
<IconRefreshOutlineMedium size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -500,7 +500,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
|
||||
title={t('changesOpenDiffTab')}
|
||||
onClick={onExpand}
|
||||
>
|
||||
<IconRightUpOutline16 size={14} />
|
||||
<IconRightUpOutlineMedium size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -543,7 +543,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
|
||||
title={t('changesClosePreview')}
|
||||
onClick={onClose}
|
||||
>
|
||||
<IconCloseOutline16 size={14} />
|
||||
<IconCloseOutlineMedium size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{target.kind === 'op' && htmlOp && rendering && htmlRenderSrc !== ''
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import {
|
||||
Button, IconCodeOutline16, IconCopyOutline16, IconPlusOutline16,
|
||||
IconRefreshOutline16, IconTrashOutline16, Input, Menu, Modal, writeClipboard,
|
||||
Button, IconCodeOutlineMedium, IconCopyOutlineMedium, IconPlusOutlineMedium,
|
||||
IconRefreshOutlineMedium, IconTrashOutlineMedium, Input, Menu, Modal, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, SessionScope } from '../api.ts'
|
||||
import { api } from '../api.ts'
|
||||
@@ -450,7 +450,7 @@ export function GitLens(props: GitLensProps) {
|
||||
disabled={busy}
|
||||
onClick={() => { void stageEntry(entry, staged) }}
|
||||
>
|
||||
{staged ? <IconTrashOutline16 /> : <IconPlusOutline16 />}
|
||||
{staged ? <IconTrashOutlineMedium /> : <IconPlusOutlineMedium />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@@ -504,7 +504,7 @@ export function GitLens(props: GitLensProps) {
|
||||
title={t('refresh')}
|
||||
onClick={() => { void refresh() }}
|
||||
>
|
||||
<IconRefreshOutline16 size={14} />
|
||||
<IconRefreshOutlineMedium size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -624,17 +624,17 @@ export function GitLens(props: GitLensProps) {
|
||||
// that confuses the user; with the fence disarmed (the
|
||||
// `workspaceFence` pref) the open is allowed through.
|
||||
...(fileMenu !== null && (store.getPrefs().workspaceFence === false || isWithinWorkspace(scope.cwd ?? '', resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, fileMenu.entry.path)))
|
||||
? [{ id: 'open', label: t('openEditor'), icon: <IconCodeOutline16 size={14} /> }]
|
||||
? [{ id: 'open', label: t('openEditor'), icon: <IconCodeOutlineMedium size={14} /> }]
|
||||
: []),
|
||||
fileMenu?.staged === true
|
||||
? { id: 'stage', label: t('unstage'), icon: <IconTrashOutline16 size={14} /> }
|
||||
: { id: 'stage', label: t('stage'), icon: <IconPlusOutline16 size={14} /> },
|
||||
? { id: 'stage', label: t('unstage'), icon: <IconTrashOutlineMedium size={14} /> }
|
||||
: { id: 'stage', label: t('stage'), icon: <IconPlusOutlineMedium size={14} /> },
|
||||
...(fileMenu !== null && !isUntracked(fileMenu.entry)
|
||||
? [{ id: 'discard', label: t('discard'), icon: <IconTrashOutline16 size={14} />, danger: true }]
|
||||
? [{ id: 'discard', label: t('discard'), icon: <IconTrashOutlineMedium size={14} />, danger: true }]
|
||||
: []),
|
||||
{ type: 'separator', id: 'sep1' },
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutlineMedium size={14} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutlineMedium size={14} /> },
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
const target = fileMenu
|
||||
@@ -683,9 +683,9 @@ export function GitLens(props: GitLensProps) {
|
||||
onClose={() => { setHistoryMenu(null) }}
|
||||
items={[
|
||||
{ id: 'view', label: t('viewCommitDiff') },
|
||||
{ id: 'copyShort', label: t('copyShortHash'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'copyFull', label: t('copyFullHash'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'copySubject', label: t('copySubject'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'copyShort', label: t('copyShortHash'), icon: <IconCopyOutlineMedium size={14} /> },
|
||||
{ id: 'copyFull', label: t('copyFullHash'), icon: <IconCopyOutlineMedium size={14} /> },
|
||||
{ id: 'copySubject', label: t('copySubject'), icon: <IconCopyOutlineMedium size={14} /> },
|
||||
{ type: 'separator', id: 'sep2' },
|
||||
{ id: 'revert', label: t('revertCommit'), danger: true },
|
||||
{ id: 'cherryPick', label: t('cherryPickCommit'), danger: true },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* deliverables entry; when nothing was produced the selector returns null
|
||||
* and the original row renders unchanged.
|
||||
*/
|
||||
import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconCodeOutlineMedium } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { revealPaths, type SidebarStore } from './state.ts'
|
||||
import { t } from './locales.ts'
|
||||
@@ -59,6 +59,7 @@ export function SidebarProducedFiles(props: {
|
||||
onShowInFolder: (files: readonly string[]) => void
|
||||
}) {
|
||||
const { matched, openInSidebar, onShowInFolder } = props
|
||||
if (!matched || matched.length === 0) return null
|
||||
const shown = matched.slice(0, 6)
|
||||
const hidden = matched.length - shown.length
|
||||
return (
|
||||
@@ -75,7 +76,7 @@ export function SidebarProducedFiles(props: {
|
||||
title={path}
|
||||
onClick={() => { openInSidebar(path) }}
|
||||
>
|
||||
<IconCodeOutline16 size={12} />
|
||||
<IconCodeOutlineMedium size={12} />
|
||||
<span>{name}</span>
|
||||
</button>
|
||||
)
|
||||
@@ -112,6 +113,7 @@ export function SidebarProducedFiles(props: {
|
||||
export function registerTurnTailInterception(ctx: Context, store: SidebarStore): () => void {
|
||||
return ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
|
||||
name: 'conversation.chat.turnTail',
|
||||
id: 'dsh-apes-sidebar:turn-tail',
|
||||
// Decline the takeover while the editor tab type is disabled in the side
|
||||
// card settings: the produced-files row falls back to the default
|
||||
// deliverables behavior instead of offering chips that cannot open. Also
|
||||
|
||||
@@ -327,6 +327,7 @@ export const ar: Record<string, string> = {
|
||||
subagentEmptyDesc: 'الوكلاء الفرعيون المُنتَجون تحت الوكيل الرئيسي سيظهرون هنا',
|
||||
subagentRunning: 'قيد التشغيل',
|
||||
subagentInactive: 'خامل',
|
||||
subagentOpenAside: 'الفتح في الشريط الجانبي',
|
||||
subagentModeOneShot: 'مرة واحدة',
|
||||
subagentModeContinuable: 'قابل للاستئناف',
|
||||
subagentCount: '{count} وكلاء فرعيون',
|
||||
@@ -369,6 +370,14 @@ export const ar: Record<string, string> = {
|
||||
sideChatBlockRunning: 'قيد التشغيل',
|
||||
sideChatBlockFailed: 'فشل',
|
||||
sideChatBlockDone: 'اكتمل',
|
||||
sideChatBlockNoExitCode: '(no exit code)',
|
||||
|
||||
codeLabel: 'Code',
|
||||
|
||||
wrapLabel: 'Wrap',
|
||||
|
||||
unwrapLabel: 'Unwrap',
|
||||
|
||||
sideChatBlockNoOutput: '(لا يوجد إخراج)',
|
||||
sideChatBlockFiles: '{count} ملفات',
|
||||
sideChatBlockWindow: '{shown} من {total} سطرًا',
|
||||
|
||||
@@ -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',
|
||||
@@ -354,6 +355,13 @@ export const de: Record<string, string> = {
|
||||
sideChatBlockRunning: 'Läuft',
|
||||
sideChatBlockFailed: 'Fehlgeschlagen',
|
||||
sideChatBlockDone: 'Fertig',
|
||||
sideChatBlockNoExitCode: '(kein Exit-Code)',
|
||||
|
||||
codeLabel: 'Code',
|
||||
|
||||
wrapLabel: 'Wrap',
|
||||
|
||||
unwrapLabel: 'Unwrap',
|
||||
sideChatBlockNoOutput: '(keine Ausgabe)',
|
||||
sideChatBlockFiles: '{count} Dateien',
|
||||
sideChatBlockWindow: '{shown} von {total} Zeilen',
|
||||
|
||||
@@ -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)',
|
||||
@@ -361,6 +362,14 @@ export const fr: Record<string, string> = {
|
||||
sideChatBlockRunning: 'En cours',
|
||||
sideChatBlockFailed: 'Échec',
|
||||
sideChatBlockDone: 'Terminé',
|
||||
sideChatBlockNoExitCode: '(no exit code)',
|
||||
|
||||
codeLabel: 'Code',
|
||||
|
||||
wrapLabel: 'Wrap',
|
||||
|
||||
unwrapLabel: 'Unwrap',
|
||||
|
||||
sideChatBlockNoOutput: '(aucune sortie)',
|
||||
sideChatBlockFiles: '{count} fichiers',
|
||||
sideChatBlockWindow: '{shown} sur {total} lignes',
|
||||
|
||||
@@ -326,6 +326,7 @@ export const hi: Record<string, string> = {
|
||||
subagentEmptyDesc: 'मुख्य एजेंट के अंतर्गत बने सबएजेंट यहाँ दिखेंगे',
|
||||
subagentRunning: 'चल रहा',
|
||||
subagentInactive: 'निष्क्रिय',
|
||||
subagentOpenAside: 'साइडबार में खोलें',
|
||||
subagentModeOneShot: 'एक-बार',
|
||||
subagentModeContinuable: 'जारी रखने योग्य',
|
||||
subagentCount: '{count} सबएजेंट',
|
||||
@@ -368,6 +369,14 @@ export const hi: Record<string, string> = {
|
||||
sideChatBlockRunning: 'चल रहा है',
|
||||
sideChatBlockFailed: 'विफल',
|
||||
sideChatBlockDone: 'पूर्ण',
|
||||
sideChatBlockNoExitCode: '(no exit code)',
|
||||
|
||||
codeLabel: 'Code',
|
||||
|
||||
wrapLabel: 'Wrap',
|
||||
|
||||
unwrapLabel: 'Unwrap',
|
||||
|
||||
sideChatBlockNoOutput: '(कोई आउटपुट नहीं)',
|
||||
sideChatBlockFiles: '{count} फ़ाइलें',
|
||||
sideChatBlockWindow: 'कुल {total} में से {shown} पंक्तियाँ',
|
||||
|
||||
@@ -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',
|
||||
@@ -366,6 +367,14 @@ export const id: Record<string, string> = {
|
||||
sideChatBlockRunning: 'Berjalan',
|
||||
sideChatBlockFailed: 'Gagal',
|
||||
sideChatBlockDone: 'Selesai',
|
||||
sideChatBlockNoExitCode: '(no exit code)',
|
||||
|
||||
codeLabel: 'Code',
|
||||
|
||||
wrapLabel: 'Wrap',
|
||||
|
||||
unwrapLabel: 'Unwrap',
|
||||
|
||||
sideChatBlockNoOutput: '(tanpa output)',
|
||||
sideChatBlockFiles: '{count} file',
|
||||
sideChatBlockWindow: '{shown} dari {total} baris',
|
||||
|
||||
@@ -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',
|
||||
@@ -359,6 +360,14 @@ export const it: Record<string, string> = {
|
||||
sideChatBlockRunning: 'In esecuzione',
|
||||
sideChatBlockFailed: 'Non riuscito',
|
||||
sideChatBlockDone: 'Completato',
|
||||
sideChatBlockNoExitCode: '(no exit code)',
|
||||
|
||||
codeLabel: 'Code',
|
||||
|
||||
wrapLabel: 'Wrap',
|
||||
|
||||
unwrapLabel: 'Unwrap',
|
||||
|
||||
sideChatBlockNoOutput: '(nessun output)',
|
||||
sideChatBlockFiles: '{count} file',
|
||||
sideChatBlockWindow: '{shown} di {total} righe',
|
||||
|
||||
@@ -326,6 +326,7 @@ export const ja: Record<string, string> = {
|
||||
subagentEmptyDesc: 'メインエージェントが派生したサブエージェントはここに表示されます',
|
||||
subagentRunning: '実行中',
|
||||
subagentInactive: 'アイドル',
|
||||
subagentOpenAside: 'サイドバーで開く',
|
||||
subagentModeOneShot: 'ワンショット',
|
||||
subagentModeContinuable: '継続可',
|
||||
subagentCount: '{count} 個のサブエージェント',
|
||||
@@ -368,6 +369,14 @@ export const ja: Record<string, string> = {
|
||||
sideChatBlockRunning: '実行中',
|
||||
sideChatBlockFailed: '失敗',
|
||||
sideChatBlockDone: '完了',
|
||||
sideChatBlockNoExitCode: '(no exit code)',
|
||||
|
||||
codeLabel: 'Code',
|
||||
|
||||
wrapLabel: 'Wrap',
|
||||
|
||||
unwrapLabel: 'Unwrap',
|
||||
|
||||
sideChatBlockNoOutput: '(出力なし)',
|
||||
sideChatBlockFiles: '{count} 個のファイル',
|
||||
sideChatBlockWindow: '全 {total} 行中 {shown} 行を表示',
|
||||
|
||||
@@ -318,6 +318,7 @@ export const ko: Record<string, string> = {
|
||||
subagentEmptyDesc: '현재 주 에이전트에서 파생된 서브 에이전트가 여기에 표시됩니다',
|
||||
subagentRunning: '실행 중',
|
||||
subagentInactive: '대기 중',
|
||||
subagentOpenAside: '사이드바에서 열기',
|
||||
subagentModeOneShot: '일회성',
|
||||
subagentModeContinuable: '이어서 가능',
|
||||
subagentCount: '서브 에이전트 {count}개',
|
||||
@@ -360,6 +361,14 @@ export const ko: Record<string, string> = {
|
||||
sideChatBlockRunning: '실행 중',
|
||||
sideChatBlockFailed: '실패',
|
||||
sideChatBlockDone: '완료',
|
||||
sideChatBlockNoExitCode: '(no exit code)',
|
||||
|
||||
codeLabel: 'Code',
|
||||
|
||||
wrapLabel: 'Wrap',
|
||||
|
||||
unwrapLabel: 'Unwrap',
|
||||
|
||||
sideChatBlockNoOutput: '(출력 없음)',
|
||||
sideChatBlockFiles: '파일 {count}개',
|
||||
sideChatBlockWindow: '전체 {total}줄 중 {shown}줄 표시',
|
||||
|
||||
@@ -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',
|
||||
@@ -366,6 +367,13 @@ export const nl: Record<string, string> = {
|
||||
sideChatBlockRunning: 'Wordt uitgevoerd',
|
||||
sideChatBlockFailed: 'Mislukt',
|
||||
sideChatBlockDone: 'Voltooid',
|
||||
sideChatBlockNoExitCode: '(geen exitcode)',
|
||||
|
||||
codeLabel: 'Code',
|
||||
|
||||
wrapLabel: 'Wrap',
|
||||
|
||||
unwrapLabel: 'Unwrap',
|
||||
sideChatBlockNoOutput: '(geen uitvoer)',
|
||||
sideChatBlockFiles: '{count} bestanden',
|
||||
sideChatBlockWindow: '{shown} van {total} regels',
|
||||
|
||||
@@ -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',
|
||||
@@ -370,6 +371,14 @@ export const pl: Record<string, string> = {
|
||||
sideChatBlockRunning: 'Uruchomione',
|
||||
sideChatBlockFailed: 'Niepowodzenie',
|
||||
sideChatBlockDone: 'Zakończono',
|
||||
sideChatBlockNoExitCode: '(no exit code)',
|
||||
|
||||
codeLabel: 'Code',
|
||||
|
||||
wrapLabel: 'Wrap',
|
||||
|
||||
unwrapLabel: 'Unwrap',
|
||||
|
||||
sideChatBlockNoOutput: '(brak wyniku)',
|
||||
sideChatBlockFiles: 'Pliki: {count}',
|
||||
sideChatBlockWindow: '{shown} z {total} wierszy',
|
||||
|
||||
@@ -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',
|
||||
@@ -351,6 +352,14 @@ export const pt: Record<string, string> = {
|
||||
sideChatBlockRunning: 'Em execução',
|
||||
sideChatBlockFailed: 'Falhou',
|
||||
sideChatBlockDone: 'Concluído',
|
||||
sideChatBlockNoExitCode: '(no exit code)',
|
||||
|
||||
codeLabel: 'Code',
|
||||
|
||||
wrapLabel: 'Wrap',
|
||||
|
||||
unwrapLabel: 'Unwrap',
|
||||
|
||||
sideChatBlockNoOutput: '(sem saída)',
|
||||
sideChatBlockFiles: '{count} arquivos',
|
||||
sideChatBlockWindow: '{shown} de {total} linhas',
|
||||
|
||||
@@ -324,6 +324,7 @@ export const ru: Record<string, string> = {
|
||||
subagentEmptyDesc: 'Субагенты, порождённые главным агентом, появятся здесь',
|
||||
subagentRunning: 'Выполняется',
|
||||
subagentInactive: 'Простаивает',
|
||||
subagentOpenAside: 'Открыть на боковой панели',
|
||||
subagentModeOneShot: 'Одноразовый',
|
||||
subagentModeContinuable: 'Продолжаемый',
|
||||
subagentCount: '{count} субагентов',
|
||||
@@ -366,6 +367,14 @@ export const ru: Record<string, string> = {
|
||||
sideChatBlockRunning: 'Выполняется',
|
||||
sideChatBlockFailed: 'Ошибка',
|
||||
sideChatBlockDone: 'Готово',
|
||||
sideChatBlockNoExitCode: '(no exit code)',
|
||||
|
||||
codeLabel: 'Code',
|
||||
|
||||
wrapLabel: 'Wrap',
|
||||
|
||||
unwrapLabel: 'Unwrap',
|
||||
|
||||
sideChatBlockNoOutput: '(нет вывода)',
|
||||
sideChatBlockFiles: 'Файлов: {count}',
|
||||
sideChatBlockWindow: '{shown} из {total} строк',
|
||||
|
||||
@@ -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',
|
||||
@@ -351,6 +352,14 @@ export const sv: Record<string, string> = {
|
||||
sideChatBlockRunning: 'Körs',
|
||||
sideChatBlockFailed: 'Misslyckades',
|
||||
sideChatBlockDone: 'Klar',
|
||||
sideChatBlockNoExitCode: '(no exit code)',
|
||||
|
||||
codeLabel: 'Code',
|
||||
|
||||
wrapLabel: 'Wrap',
|
||||
|
||||
unwrapLabel: 'Unwrap',
|
||||
|
||||
sideChatBlockNoOutput: '(ingen utdata)',
|
||||
sideChatBlockFiles: '{count} filer',
|
||||
sideChatBlockWindow: '{shown} av {total} rader',
|
||||
|
||||
@@ -326,6 +326,7 @@ export const th: Record<string, string> = {
|
||||
subagentEmptyDesc: 'ตัวแทนย่อยที่สร้างภายใต้ตัวแทนหลักจะปรากฏที่นี่',
|
||||
subagentRunning: 'กำลังทำงาน',
|
||||
subagentInactive: 'ไม่ได้ทำงาน',
|
||||
subagentOpenAside: 'เปิดในแถบด้านข้าง',
|
||||
subagentModeOneShot: 'ครั้งเดียว',
|
||||
subagentModeContinuable: 'ทำต่อได้',
|
||||
subagentCount: '{count} ตัวแทนย่อย',
|
||||
@@ -368,6 +369,14 @@ export const th: Record<string, string> = {
|
||||
sideChatBlockRunning: 'กำลังทำงาน',
|
||||
sideChatBlockFailed: 'ล้มเหลว',
|
||||
sideChatBlockDone: 'เสร็จสิ้น',
|
||||
sideChatBlockNoExitCode: '(no exit code)',
|
||||
|
||||
codeLabel: 'Code',
|
||||
|
||||
wrapLabel: 'Wrap',
|
||||
|
||||
unwrapLabel: 'Unwrap',
|
||||
|
||||
sideChatBlockNoOutput: '(ไม่มีเอาต์พุต)',
|
||||
sideChatBlockFiles: '{count} ไฟล์',
|
||||
sideChatBlockWindow: '{shown} จาก {total} บรรทัด',
|
||||
|
||||
@@ -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ı',
|
||||
@@ -368,6 +369,14 @@ export const tr: Record<string, string> = {
|
||||
sideChatBlockRunning: 'Çalışıyor',
|
||||
sideChatBlockFailed: 'Başarısız',
|
||||
sideChatBlockDone: 'Tamamlandı',
|
||||
sideChatBlockNoExitCode: '(no exit code)',
|
||||
|
||||
codeLabel: 'Code',
|
||||
|
||||
wrapLabel: 'Wrap',
|
||||
|
||||
unwrapLabel: 'Unwrap',
|
||||
|
||||
sideChatBlockNoOutput: '(çıktı yok)',
|
||||
sideChatBlockFiles: '{count} dosya',
|
||||
sideChatBlockWindow: '{total} satırdan {shown} satır',
|
||||
|
||||
@@ -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',
|
||||
@@ -368,6 +369,14 @@ export const vi: Record<string, string> = {
|
||||
sideChatBlockRunning: 'Đang chạy',
|
||||
sideChatBlockFailed: 'Thất bại',
|
||||
sideChatBlockDone: 'Hoàn tất',
|
||||
sideChatBlockNoExitCode: '(no exit code)',
|
||||
|
||||
codeLabel: 'Code',
|
||||
|
||||
wrapLabel: 'Wrap',
|
||||
|
||||
unwrapLabel: 'Unwrap',
|
||||
|
||||
sideChatBlockNoOutput: '(không có đầu ra)',
|
||||
sideChatBlockFiles: '{count} tệp',
|
||||
sideChatBlockWindow: '{shown} trong {total} dòng',
|
||||
|
||||
@@ -341,6 +341,7 @@ export const zhHK: Record<string, string> = {
|
||||
subagentEmptyDesc: '目前主代理派生的子代理將顯示在這裡',
|
||||
subagentRunning: '執行中',
|
||||
subagentInactive: '閒置',
|
||||
subagentOpenAside: 'Open in sidebar',
|
||||
subagentModeOneShot: '一次性',
|
||||
subagentModeContinuable: '可續接',
|
||||
subagentCount: '{count} 個子代理',
|
||||
@@ -383,6 +384,14 @@ export const zhHK: Record<string, string> = {
|
||||
sideChatBlockRunning: '執行中',
|
||||
sideChatBlockFailed: '失敗',
|
||||
sideChatBlockDone: '完成',
|
||||
sideChatBlockNoExitCode: '(no exit code)',
|
||||
|
||||
codeLabel: '代码',
|
||||
|
||||
wrapLabel: '换行',
|
||||
|
||||
unwrapLabel: '不换行',
|
||||
|
||||
sideChatBlockNoOutput: '(無輸出)',
|
||||
sideChatBlockFiles: '{count} 個檔案',
|
||||
sideChatBlockWindow: '共 {total} 行 · 顯示 {shown} 行',
|
||||
|
||||
@@ -341,6 +341,7 @@ export const zhMO: Record<string, string> = {
|
||||
subagentEmptyDesc: '目前主代理派生的子代理將顯示在這裡',
|
||||
subagentRunning: '執行中',
|
||||
subagentInactive: '閒置',
|
||||
subagentOpenAside: 'Open in sidebar',
|
||||
subagentModeOneShot: '一次性',
|
||||
subagentModeContinuable: '可續接',
|
||||
subagentCount: '{count} 個子代理',
|
||||
@@ -383,6 +384,14 @@ export const zhMO: Record<string, string> = {
|
||||
sideChatBlockRunning: '執行中',
|
||||
sideChatBlockFailed: '失敗',
|
||||
sideChatBlockDone: '完成',
|
||||
sideChatBlockNoExitCode: '(no exit code)',
|
||||
|
||||
codeLabel: '代码',
|
||||
|
||||
wrapLabel: '换行',
|
||||
|
||||
unwrapLabel: '不换行',
|
||||
|
||||
sideChatBlockNoOutput: '(無輸出)',
|
||||
sideChatBlockFiles: '{count} 個檔案',
|
||||
sideChatBlockWindow: '共 {total} 行 · 顯示 {shown} 行',
|
||||
|
||||
@@ -341,6 +341,7 @@ export const zhTW: Record<string, string> = {
|
||||
subagentEmptyDesc: '目前主代理派生的子代理將顯示在這裡',
|
||||
subagentRunning: '執行中',
|
||||
subagentInactive: '閒置',
|
||||
subagentOpenAside: '在側欄中開啟',
|
||||
subagentModeOneShot: '一次性',
|
||||
subagentModeContinuable: '可續接',
|
||||
subagentCount: '{count} 個子代理',
|
||||
@@ -383,6 +384,14 @@ export const zhTW: Record<string, string> = {
|
||||
sideChatBlockRunning: '執行中',
|
||||
sideChatBlockFailed: '失敗',
|
||||
sideChatBlockDone: '完成',
|
||||
sideChatBlockNoExitCode: '(no exit code)',
|
||||
|
||||
codeLabel: '代码',
|
||||
|
||||
wrapLabel: '换行',
|
||||
|
||||
unwrapLabel: '不换行',
|
||||
|
||||
sideChatBlockNoOutput: '(無輸出)',
|
||||
sideChatBlockFiles: '{count} 個檔案',
|
||||
sideChatBlockWindow: '共 {total} 行 · 顯示 {shown} 行',
|
||||
|
||||
@@ -338,6 +338,7 @@ export const zh = {
|
||||
subagentEmptyDesc: '当前主代理派生的子代理将显示在这里',
|
||||
subagentRunning: '运行中',
|
||||
subagentInactive: '空闲',
|
||||
subagentOpenAside: '在侧栏中打开',
|
||||
subagentModeOneShot: '一次性',
|
||||
subagentModeContinuable: '可续接',
|
||||
subagentCount: '{count} 个子代理',
|
||||
@@ -380,6 +381,13 @@ export const zh = {
|
||||
sideChatBlockRunning: '运行中',
|
||||
sideChatBlockFailed: '失败',
|
||||
sideChatBlockDone: '完成',
|
||||
sideChatBlockNoExitCode: '无退出码',
|
||||
|
||||
codeLabel: '代码',
|
||||
|
||||
wrapLabel: '换行',
|
||||
|
||||
unwrapLabel: '不换行',
|
||||
sideChatBlockNoOutput: '(无输出)',
|
||||
sideChatBlockFiles: '{count} 个文件',
|
||||
sideChatBlockWindow: '共 {total} 行 · 显示 {shown} 行',
|
||||
@@ -780,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',
|
||||
@@ -822,6 +831,10 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
sideChatBlockRunning: 'Running',
|
||||
sideChatBlockFailed: 'Failed',
|
||||
sideChatBlockDone: 'Done',
|
||||
sideChatBlockNoExitCode: '(no exit code)',
|
||||
codeLabel: 'Code',
|
||||
wrapLabel: 'Wrap',
|
||||
unwrapLabel: 'Unwrap',
|
||||
sideChatBlockNoOutput: '(no output)',
|
||||
sideChatBlockFiles: '{count} files',
|
||||
sideChatBlockWindow: '{shown} of {total} lines',
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { IconListPenOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconListPenOutlineMedium } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
@@ -136,7 +136,7 @@ export function MdToc(): ReactNode {
|
||||
aria-expanded={open}
|
||||
onClick={() => { setOpen(!open) }}
|
||||
>
|
||||
<IconListPenOutline16 />
|
||||
<IconListPenOutlineMedium />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type MouseEv
|
||||
import { createPortal } from 'react-dom'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import mermaid from 'mermaid'
|
||||
import { IconCopyOutline16, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconCopyOutlineMedium, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
||||
import { markdownTextProps } from './markdown-labels.tsx'
|
||||
import { t } from './locales.ts'
|
||||
@@ -288,7 +288,7 @@ function MermaidDiagram({ code }: { code: string }): React.ReactNode {
|
||||
aria-label={t('copy')}
|
||||
title={t('copy')}
|
||||
>
|
||||
<IconCopyOutline16 />
|
||||
<IconCopyOutlineMedium />
|
||||
<span>{copied ? t('copied') : t('copy')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* this component owns only its own section.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { IconCloseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconCloseOutlineMedium } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
isValidCustomEditor,
|
||||
newCustomEditorId,
|
||||
@@ -121,7 +121,7 @@ export function OpenWithSettings(props: {
|
||||
title={t('openWithSettingsRemove')}
|
||||
onClick={() => { removeCustom(editor.id) }}
|
||||
>
|
||||
<IconCloseOutline16 size={14} />
|
||||
<IconCloseOutlineMedium size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
+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>
|
||||
}
|
||||
|
||||
|
||||
+28
-14
@@ -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,
|
||||
@@ -799,22 +798,28 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
}
|
||||
}
|
||||
ctx.inject(['settings'], (sctx) => {
|
||||
// DSH 0.1.2-alpha.2 validates namespaces at compile time
|
||||
// (SettingsNamespaceInput); the 'dsh-apes-sidebar' literal passes, so the
|
||||
// runtime helper this used to call (settingsNamespace) is gone upstream.
|
||||
const ns = SIDEBAR_PREFS_NS
|
||||
// The structural settings mirror types `schema` as unknown, so the
|
||||
// generic is not inferred here; the real service resolves it from the
|
||||
// schemastery schema (PrefsSchema) — narrow the owner scope explicitly.
|
||||
const scope = sctx.settings.register(ns, PrefsSchema) as {
|
||||
get(): SidebarPrefs
|
||||
watch(callback: (next: SidebarPrefs, prev: SidebarPrefs) => void): () => void
|
||||
// 0.2.0-rc.2: `settings.register()` is gone. The plugin's Config schema
|
||||
// (config.ts → Config) is auto-registered by the loader under ns =
|
||||
// entry.options.id (= package name). We read it through describe() and
|
||||
// watch for changes via the `settings/document-updated` event.
|
||||
const readPrefs = (): SidebarPrefs => {
|
||||
const descriptor = sctx.settings.describe({ redactSecrets: true }).find(c => c.ns === ns)
|
||||
const raw = descriptor?.value as Record<string, unknown> | undefined
|
||||
return raw !== null && typeof raw === 'object'
|
||||
? raw as unknown as SidebarPrefs
|
||||
: SIDEBAR_PREFS_DEFAULTS
|
||||
}
|
||||
const scope = { get: readPrefs }
|
||||
const viewOf = (): { value?: unknown; revision?: number } => {
|
||||
const descriptor = sctx.settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === ns)
|
||||
return descriptor === undefined
|
||||
? { value: undefined, revision: undefined }
|
||||
: { value: descriptor.value, revision: descriptor.revision }
|
||||
if (descriptor === undefined) return { value: undefined, revision: undefined }
|
||||
// 0.2.0-rc.2: describe() may return value=undefined for a namespace that
|
||||
// has no user-set document yet (the Config schema defaults are applied
|
||||
// lazily). Fall back to the schema defaults so the client always gets a
|
||||
// well-formed object (React #130 guard).
|
||||
const value = descriptor.value ?? SIDEBAR_PREFS_DEFAULTS
|
||||
return { value, revision: descriptor.revision }
|
||||
}
|
||||
// Mutual exclusion with the dsh-web-ui family right panel: the aionui
|
||||
// panel's provider choice (`aionui-panel.rightPanel`) is the authority.
|
||||
@@ -869,7 +874,12 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// ONE watch subscription drives both gates: settings commits re-evaluate
|
||||
// the terminal tools AND the open tool together (each gate is idempotent
|
||||
// and owns its own disposer).
|
||||
scope.watch(() => { syncToolsGate(scope); syncOpenToolsGate() })
|
||||
// 0.2.0-rc.2: `scope.watch()` is gone; use the `settings/document-updated`
|
||||
// event emitted on the plugin context (ownerContext) by the SettingsForms
|
||||
// service on every describe() call that detects a raw-value change.
|
||||
ctx.on('settings/document-updated', (changedNs: string) => {
|
||||
if (changedNs === ns) { syncToolsGate(scope); syncOpenToolsGate() }
|
||||
})
|
||||
})
|
||||
|
||||
// ── JSON API ────────────────────────────────────────────────────────────
|
||||
@@ -880,6 +890,10 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// effect releases the listener on fiber disposal.
|
||||
const assistantLive = createAssistantLiveBuffer(ctx)
|
||||
ctx.effect(() => () => { assistantLive.dispose() }, 'dsh-apes-sidebar: live assistant stream buffer')
|
||||
// NOTE: `buildApi` must come AFTER `ctx.inject(['settings'], ...)` so the
|
||||
// closure captures the settingsFace value set inside the inject callback.
|
||||
// 0.2.0-rc.2 removed `settings.register()`; settingsFace is now populated
|
||||
// synchronously during inject, not lazily at first call.
|
||||
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, terminalShell, () => settingsFace, assistantLive)
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
liveEventsOf,
|
||||
resolvePresetId,
|
||||
SIDE_BOUNDARY_PROMPT,
|
||||
SIDE_INJECTION_PLUGIN,
|
||||
SIDE_NEW_THREAD_TITLE,
|
||||
sideLabel,
|
||||
type SeedEvent,
|
||||
@@ -159,7 +158,7 @@ function admitFollowup(agent: Agent, blocks: ContentBlock[]): void {
|
||||
function admitFirstContact(agent: Agent, injectionText: string, question: string): void {
|
||||
agent.inject(createUserMessage({
|
||||
content: textPrompt(injectionText),
|
||||
source: { kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN },
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
admitFollowup(agent, textPrompt(question))
|
||||
}
|
||||
|
||||
+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', () => {
|
||||
|
||||
@@ -41,6 +41,9 @@ if (g.document === undefined) {
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
styleSheets: [],
|
||||
// KaTeX probes compatMode at module load; without it it warns
|
||||
// "doesn't work in quirks mode" and some layout paths no-op.
|
||||
compatMode: 'CSS1Compat',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +53,9 @@ if (g.navigator === undefined) {
|
||||
|
||||
if (g.window === undefined) {
|
||||
g.window = {
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => true,
|
||||
clearTimeout: () => {},
|
||||
setTimeout: (_fn: () => void) => 0,
|
||||
innerWidth: 1024,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent'
|
||||
import { buildSidechatApi } from '../src/sidechat-routes.ts'
|
||||
import { SidebarError } from '../src/wire.ts'
|
||||
import { SIDE_BOUNDARY_PROMPT, SIDE_INJECTION_PLUGIN, SIDE_NEW_THREAD_TITLE, sideLabel } from '../src/sidechat-core.ts'
|
||||
import { SIDE_BOUNDARY_PROMPT, SIDE_NEW_THREAD_TITLE, sideLabel } from '../src/sidechat-core.ts'
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
|
||||
/** A fake live agent (inject/followup/cancel spied). */
|
||||
@@ -165,7 +165,7 @@ describe('sidechat.start', () => {
|
||||
// context, no wake), the question is the follow-up that wakes the driver.
|
||||
expect(child.inject).toHaveBeenCalledTimes(1)
|
||||
const injection = child.inject.mock.calls[0]![0] as { content: Array<{ type: string; text: string }>; source: { kind: string; plugin: string } }
|
||||
expect(injection.source).toEqual({ kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN })
|
||||
expect(injection.source).toEqual({ kind: 'user' })
|
||||
expect(injection.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
|
||||
expect(injection.content[0]!.text).not.toContain('explain the event flow')
|
||||
expect(child.followup).toHaveBeenCalledTimes(1)
|
||||
@@ -259,7 +259,7 @@ describe('sidechat.start', () => {
|
||||
await api['sidechat.prompt']({ childId, text: 'explain the event flow' })
|
||||
expect(child.inject).toHaveBeenCalledTimes(1)
|
||||
const injection = child.inject.mock.calls[0]![0] as { content: Array<{ text: string }>; source: { kind: string } }
|
||||
expect(injection.source.kind).toBe('plugin')
|
||||
expect(injection.source.kind).toBe('user')
|
||||
expect(injection.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
|
||||
expect(injection.content[0]!.text).toContain('`bash` (executing)')
|
||||
expect(child.followup).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -185,7 +185,8 @@ describe('sidechat seed fork markers vs the reconstructed inbox', () => {
|
||||
turn: 2, step: 1,
|
||||
message: {
|
||||
id: 'm-r',
|
||||
role: 'user',
|
||||
role: 'tool',
|
||||
toolCallId: 'c1',
|
||||
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'ok' }] }],
|
||||
source: { kind: 'tool', callId: 'c1' },
|
||||
},
|
||||
|
||||
+78
-50
@@ -11,6 +11,7 @@ import { join, resolve as resolvePath } from 'node:path'
|
||||
import { SettingsConflictError, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { apply, mediaTypeForPath, wsCloseReasonOf } from '../src/index.ts'
|
||||
import { SidebarError } from '../src/wire.ts'
|
||||
import { SIDEBAR_PREFS_DEFAULTS, 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'
|
||||
@@ -828,29 +829,31 @@ describe('session cwd resolution over the API route', () => {
|
||||
})
|
||||
|
||||
describe('side card settings routes', () => {
|
||||
/** A minimal settings seam: register/describe/update with the revision guard. */
|
||||
/** A minimal settings seam: describe/update with the revision guard (0.2.0-rc.2 API). */
|
||||
const createFakeSettings = (pre?: Record<string, Record<string, unknown>>) => {
|
||||
const namespaces = new Map<string, {
|
||||
schema: unknown
|
||||
value: Record<string, unknown> | undefined
|
||||
revision: number
|
||||
}>()
|
||||
// 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 || !(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(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 })
|
||||
namespaces.set(ns, { schema: (input: unknown) => input ?? {}, value, revision: 0 })
|
||||
}
|
||||
const resolve = (entry: { schema: unknown; value: Record<string, unknown> | undefined }): unknown => {
|
||||
const schema = entry.schema as (input: unknown) => unknown
|
||||
return entry.value === undefined ? schema(undefined) : schema(entry.value)
|
||||
}
|
||||
return {
|
||||
register(ns: string, schema: unknown) {
|
||||
// Preserve a pre-seeded value: tests stage prefs through the `pre`
|
||||
// map before the plugin mounts and registers the same namespace.
|
||||
const existing = namespaces.get(ns)
|
||||
namespaces.set(ns, { schema, value: existing?.value ?? undefined, revision: 0 })
|
||||
return { get: () => ({}), watch: () => () => {}, update: async () => {}, replace: async () => {} }
|
||||
},
|
||||
describe() {
|
||||
describe(_options?: { redactSecrets?: boolean }) {
|
||||
return [...namespaces.entries()].map(([ns, entry]) => ({
|
||||
ns,
|
||||
value: resolve(entry),
|
||||
@@ -864,7 +867,9 @@ describe('side card settings routes', () => {
|
||||
if (expectedRevision !== undefined && expectedRevision !== entry.revision) {
|
||||
throw new SettingsConflictError(ns as SettingsNamespace, expectedRevision, entry.revision)
|
||||
}
|
||||
entry.value = { ...entry.value, ...patch }
|
||||
// The real SettingsForms re-applies the full schema (with defaults)
|
||||
// on every update, so the stored value is always a complete object.
|
||||
entry.value = { ...SIDEBAR_PREFS_DEFAULTS, ...entry.value, ...patch }
|
||||
entry.revision += 1
|
||||
},
|
||||
}
|
||||
@@ -872,6 +877,9 @@ describe('side card settings routes', () => {
|
||||
|
||||
const mountWithSettings = (settings?: unknown): SidebarWebRoute => {
|
||||
const routes: SidebarWebRoute[] = []
|
||||
// Event bus: the 0.2.0-rc.2 settings service emits
|
||||
// `settings/document-updated` on the plugin context after each update.
|
||||
const eventHandlers = new Map<string, Array<(...args: unknown[]) => void>>()
|
||||
const ctx = {
|
||||
webRuntime: { trustedHosts: [] },
|
||||
webServer: {
|
||||
@@ -882,11 +890,33 @@ describe('side card settings routes', () => {
|
||||
tools: { register: () => () => {} },
|
||||
effect: (fn: () => void | (() => void)) => { fn() },
|
||||
inject: (deps: string[], callback: (sctx: { settings: unknown }) => void) => {
|
||||
if (deps.includes('settings') && settings !== undefined) callback({ settings })
|
||||
if (deps.includes('settings') && settings !== undefined) {
|
||||
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)
|
||||
s.update = async (ns: string, ...rest: unknown[]) => {
|
||||
await origUpdate(ns, ...rest)
|
||||
const handlers = eventHandlers.get('settings/document-updated') ?? []
|
||||
for (const h of handlers) h(ns)
|
||||
}
|
||||
callback({ settings: s })
|
||||
}
|
||||
return () => {}
|
||||
},
|
||||
// The session/agent event feeds: nothing emits in these tests.
|
||||
on: () => () => {},
|
||||
on: (event: string, handler: (...args: unknown[]) => void) => {
|
||||
const existing = eventHandlers.get(event) ?? []
|
||||
existing.push(handler)
|
||||
eventHandlers.set(event, existing)
|
||||
return () => {
|
||||
const handlers = eventHandlers.get(event) ?? []
|
||||
const idx = handlers.indexOf(handler)
|
||||
if (idx >= 0) handlers.splice(idx, 1)
|
||||
}
|
||||
},
|
||||
// No jobs/agents services: the jobs routes degrade to a 503.
|
||||
get: () => undefined,
|
||||
}
|
||||
@@ -949,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',
|
||||
},
|
||||
@@ -1002,6 +1032,9 @@ describe('side card settings routes', () => {
|
||||
workspaceFence: true,
|
||||
terminalShell: '',
|
||||
terminalShellArgs: '',
|
||||
titleBarScheme: 'auto',
|
||||
titleBarPresetId: '',
|
||||
customCss: '',
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: 40,
|
||||
htmlViewerNoSandbox: false,
|
||||
@@ -1144,23 +1177,18 @@ describe('agent terminal tool gating', () => {
|
||||
it('injects the eight tools only when the side-card setting is enabled (default off)', () => {
|
||||
let registered = 0
|
||||
let disposed = 0
|
||||
// The tools currently registered (registered minus disposed).
|
||||
const live = (): number => registered - disposed
|
||||
// A ref container: the watch callback is only assigned inside a closure,
|
||||
// which TypeScript's control-flow analysis ignores (the bare variable
|
||||
// would narrow to null and refuse the optional call).
|
||||
const watcherRef: { current: (() => void) | null } = { current: null }
|
||||
// 0.2.0-rc.2: no more register()/watch(). The plugin reads settings via
|
||||
// describe() and listens to `settings/document-updated` on ctx.on().
|
||||
let enabled = false
|
||||
const handlerRef: { current: ((ns: string) => void) | null } = { current: null }
|
||||
const settings = {
|
||||
register() {
|
||||
return {
|
||||
get: () => ({ agentTerminalTools: enabled }),
|
||||
watch: (callback: () => void) => { watcherRef.current = callback; return () => {} },
|
||||
update: async () => {},
|
||||
replace: async () => {},
|
||||
}
|
||||
},
|
||||
describe: () => [],
|
||||
describe: () => [{
|
||||
ns: SIDEBAR_PREFS_NS,
|
||||
value: { ...SIDEBAR_PREFS_DEFAULTS, agentTerminalTools: enabled },
|
||||
applies: 'live' as const,
|
||||
revision: 0,
|
||||
}],
|
||||
async update() {},
|
||||
}
|
||||
const ctx = {
|
||||
@@ -1176,9 +1204,10 @@ describe('agent terminal tool gating', () => {
|
||||
if (deps.includes('settings')) callback({ settings })
|
||||
return () => {}
|
||||
},
|
||||
// The session/agent event feeds: nothing emits in these tests.
|
||||
on: () => () => {},
|
||||
// No jobs/agents services: the jobs routes degrade to a 503.
|
||||
on: (event: string, handler: (...args: unknown[]) => void) => {
|
||||
if (event === 'settings/document-updated') handlerRef.current = handler as (ns: string) => void
|
||||
return () => { handlerRef.current = null }
|
||||
},
|
||||
get: () => undefined,
|
||||
}
|
||||
apply(ctx as never)
|
||||
@@ -1186,18 +1215,18 @@ describe('agent terminal tool gating', () => {
|
||||
expect(live()).toBe(0)
|
||||
// Flipping the setting on registers all eight tools.
|
||||
enabled = true
|
||||
watcherRef.current?.()
|
||||
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
|
||||
watcherRef.current?.()
|
||||
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
|
||||
watcherRef.current?.()
|
||||
handlerRef.current?.(SIDEBAR_PREFS_NS)
|
||||
expect(live()).toBe(8)
|
||||
expect(registered).toBe(16)
|
||||
})
|
||||
@@ -1208,18 +1237,15 @@ describe('agent sidebar-open tool gating', () => {
|
||||
let registered = 0
|
||||
let disposed = 0
|
||||
const live = (): number => registered - disposed
|
||||
const watcherRef: { current: (() => void) | null } = { current: null }
|
||||
let enabled = false
|
||||
const handlerRef: { current: ((ns: string) => void) | null } = { current: null }
|
||||
const settings = {
|
||||
register() {
|
||||
return {
|
||||
get: () => ({ agentOpenTools: enabled, tabsEnabled: {} }),
|
||||
watch: (callback: () => void) => { watcherRef.current = callback; return () => {} },
|
||||
update: async () => {},
|
||||
replace: async () => {},
|
||||
}
|
||||
},
|
||||
describe: () => [],
|
||||
describe: () => [{
|
||||
ns: SIDEBAR_PREFS_NS,
|
||||
value: { ...SIDEBAR_PREFS_DEFAULTS, agentOpenTools: enabled },
|
||||
applies: 'live' as const,
|
||||
revision: 0,
|
||||
}],
|
||||
async update() {},
|
||||
}
|
||||
const ctx = {
|
||||
@@ -1235,8 +1261,10 @@ describe('agent sidebar-open tool gating', () => {
|
||||
if (deps.includes('settings')) callback({ settings })
|
||||
return () => {}
|
||||
},
|
||||
// The session/agent event feeds: nothing emits in these tests.
|
||||
on: () => () => {},
|
||||
on: (event: string, handler: (...args: unknown[]) => void) => {
|
||||
if (event === 'settings/document-updated') handlerRef.current = handler as (ns: string) => void
|
||||
return () => { handlerRef.current = null }
|
||||
},
|
||||
get: () => undefined,
|
||||
}
|
||||
apply(ctx as never)
|
||||
@@ -1244,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
|
||||
watcherRef.current?.()
|
||||
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
|
||||
watcherRef.current?.()
|
||||
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
|
||||
watcherRef.current?.()
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,21 @@
|
||||
*/
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
// 上游 dsh-client-ui-primitives 的 lib 带 sourceMappingURL 注释但未发布 map
|
||||
// 文件(package.json 的 files 不含 .map)→ Vite 读 map 报 ENOENT。
|
||||
// 这里在 transform 阶段剥掉该注释;不要手改 node_modules(pnpm install 即失效)。
|
||||
const stripMissingSourcemapRefs = {
|
||||
name: 'strip-missing-sourcemap-refs',
|
||||
enforce: 'pre' as const,
|
||||
transform(code: string, id: string) {
|
||||
if (!id.includes('dsh-client-ui-primitives')) return
|
||||
if (!code.includes('sourceMappingURL')) return
|
||||
return { code: code.replace(/\/\/\/?\s*sourceMappingURL=.*$/gm, ''), map: null }
|
||||
},
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [stripMissingSourcemapRefs],
|
||||
test: {
|
||||
// Bridge Node's `localStorage` accessor to jsdom's store (see the file).
|
||||
setupFiles: ['tests/setup.ts'],
|
||||
|
||||
Reference in New Issue
Block a user