diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcb900a..e1e0359 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,10 +131,11 @@ jobs: # Pin the DSH CLI the lane mounts into; the profile's bundle stack # (dsh-base / dsh-web-app) resolves from this installation. Keep in # step with the plugin's @deepseek-ai/* peer range when bumping. - # DSH 0.1.2-rc.1 is published under the `next` dist-tag; the lanes - # speak the slash-only RPC dialect (tests/e2e/host-protocol.ts). + # DSH 0.1.5-rc.1 is the `latest` (and `next`) dist-tag on npm — the + # 0.1.5 line's first release candidate; the lanes speak the slash-only + # RPC dialect (tests/e2e/host-protocol.ts). - name: Install dsh CLI (pinned) - run: npm install -g @deepseek-ai/dsh@0.1.2-rc.1 + run: npm install -g @deepseek-ai/dsh@0.1.5-rc.1 # lib/ is gitignored; the tarball is produced from this build. - name: Build and pack the npm tarball diff --git a/AGENTS.md b/AGENTS.md index 0f8656b..7d03855 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # dsh-better-sidebar 仓库规则(AGENTS) > 本文只含**项目全局开发规则**(面向贡献者与 agent)。 -> 消费插件接入 API 全参考(`ctx.betterSidebar` 服务、TabDescriptor / FileViewerDescriptor 全字段、声明式设置、自由窗口、皮肤契约等)→ [docs/external-plugin-guide.md](docs/external-plugin-guide.md);逐特性设计史(含实施偏差记录)→ [docs/plans/](docs/plans/)。 +> 消费插件接入 API 全参考(`ctx.betterSidebar` 服务、TabDescriptor / FileViewerDescriptor 全字段、声明式设置、原生右侧栏承载面、皮肤契约等)→ [docs/external-plugin-guide.md](docs/external-plugin-guide.md);逐特性设计史(含实施偏差记录)→ [docs/plans/](docs/plans/)。 --- @@ -17,29 +17,29 @@ ## 2. CI 挂载冒烟(`plugin-mount` job / `pnpm test:mount`) -「npm 打包 → 真实挂载 → 无头渲染」门禁(证明打包产物在真实 DSH 挂载后不 crash):`pnpm build && pnpm pack` 产 tarball → `scripts/e2e-mount.sh` 装进全新 scratch profile(`dsh plugin --profile web add file:`)并启动真实 `dsh web`(keyless,`--port 0`)→ `tests/e2e/mount.e2e.ts`(Playwright)断言 `[data-dsh-better-sidebar]` 挂载、无错误条/pageerror/console 错误,经「+ 菜单」逐个打开内置 tab(含终端懒加载 chunk),再经文件树打开 seed 文件强制加载 editor chunk(`client-editor.js`)。 +「npm 打包 → 真实挂载 → 无头渲染」门禁(证明打包产物在真实 DSH 挂载后不 crash):`pnpm build && pnpm pack` 产 tarball → `scripts/e2e-mount.sh` 装进全新 scratch profile(`dsh plugin --profile web add `)并启动真实 `dsh web`(keyless,`--port 0`)→ `tests/e2e/mount.e2e.ts`(Playwright)断言 `[data-dsh-better-sidebar]` 挂载、无错误条/pageerror/console 错误,展开 DSH 原生右侧栏后经其 guide 页逐个打开插件 tab 类型(含终端懒加载 chunk),再经插件文件树(原生 `files` kind 接管)打开 seed 文件强制加载 editor chunk(`client-editor.js`),并跑 mermaid / README 预览与 sidechat 宿主路由烟测。 -本地:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`。CI 钉 `@deepseek-ai/dsh@0.1.2-rc.1`(`next` dist-tag;peer 下限 `^0.1.2-rc.1`)。e2e spec 命名 `*.e2e.ts` + vitest `exclude` 双保险;**改 `exclude` 必须保留默认排除项**(exclude 整体替换默认值)。 +本地:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`。CI 钉 `@deepseek-ai/dsh@0.1.5-rc.1`(在 npm 上同时是 `latest` 与 `next`;peer 下限 `^0.1.5-rc.1`)。e2e spec 命名 `*.e2e.ts` + vitest `exclude` 双保险;**改 `exclude` 必须保留默认排除项**(exclude 整体替换默认值)。 --- -## 3. DSH 0.1.2 适配要点(0.1.2-rc.1+ 基线) +## 3. DSH 0.1.5 适配要点(0.1.5-rc.1+ 基线) -**v0.18.0 起插件毕业正式版(npm `latest`),仅支持 DSH 0.1.2-rc.1+**(peer 下限 `^0.1.2-rc.1`,CI 钉 `@deepseek-ai/dsh@0.1.2-rc.1`,DSH rc.x 发在 `next` dist-tag)。v0.18.0-alpha.0 进入 alpha 通道时已删除对 0.1.0-rc.8 ~ 0.1.1-rc.2 的兼容层——双方言 RPC(点分回退)、MarkdownText 双形状 labels、`dsh-client-runtime` externals/inject 残留行;stable DSH 用户请固定装 v0.17.1(npm 上的旧 latest,0.1.2-alpha.x 宿主继续用 npm `alpha` 的 v0.18.0-alpha.0)。发版:release.yml 按版本号是否含 `-` 自动选 `alpha`/`latest` dist-tag。 +**v0.19.0(正式版,npm `latest`)起仅支持 DSH 0.1.5-rc.1+**(peer 下限 `^0.1.5-rc.1`,CI 钉 `@deepseek-ai/dsh@0.1.5-rc.1`)。0.1.5-alpha.2 用户停留在 **v0.19.0-alpha.1**(npm `alpha`)——rc.1 的 delta 很小,插件不再对 alpha 线做运行时兼容;0.1.2-rc.1 稳定线用户继续用 **v0.18.1**——0.1.5 的会话事件模型与文件打开漏斗都变了,插件不再对 0.1.2 做运行时兼容。发版:release.yml 按版本号是否含 `-` 自动选 `alpha`/`latest` dist-tag(0.19.0 无后缀 → `latest`;npm `alpha` 标签仍指向 `0.19.0-alpha.1`)。 宿主契约(均经真机挂载冒烟 14/14 验证): 1. **一次性 token 鉴权**:就绪行 `dsh web: http://127.0.0.1:/?token=<43字符>`(导航换签名 cookie,干净 URL 401)。`e2e-mount.sh` 的 URL grep 必须延伸到空白(`[^ ]*`,在 `/` 截断丢 token);e2e 统一走 `tests/e2e/host.ts`(token 必选:`parseLaunchUrl` 对裸 origin 直接抛错),带 stamps 导航走 `gotoPage()`(先 addCookies 再直达——token 换 cookie 的 303 会丢弃同 URL 其它 query 参数)。插件 `/sidebar/*` 路由不受影响,同源 fetch 照旧。 2. **Remote gateway 斜杠 RPC(唯一方言)**:`POST /api/workspace/create`,payload 恰为 `{args: {...}}`,**args 按控制器 TS 参数名包装**(`workspace/create`、`session/create` → `{args:{request:{...}}}`;`session/list` 参数名 `_request` **不可省略**——`{}` 也被拒 `args fields do not match the descriptor`);envelope `method` 与路径一致,点分路径 404。请求由 `tests/e2e/host-protocol.ts` 的 `rpcAttempt` 构造、`tests/e2e-host-protocol.spec.ts` 锁定;要调新方法先在真机验参数名再进 `RPC_ARGS_KEY`。 3. **`MarkdownText` labels 嵌套契约**:必填 `labels: { code: { copyLabel, copiedLabel }, footnotes }`(漏传回退硬编码中文)。四个渲染点(mermaid.tsx / MarkdownHtml.tsx / TextEditor.tsx / SideChatView.tsx)统一走 `src/client/markdown-labels.tsx` 的 `markdownTextProps()`。 -4. **侧边对话转录走自有路由,不碰客户端宿主 RPC**:`ctx.connection.api`(含 `sessions.history`)在 alpha.1 整体移除,继任 `session/follow|page` 又对 `origin:'subagent'` 会话强制 subagent 地址(普通 `{kind:'session'}` 被 `agent-busy` 拒)且分页 `throughSeq` 不得超当前游标。转录因此由 **`sidechat.events`** 插件路由供给(`src/sidechat-routes.ts`:live 读 `agent.session.snapshotEvents()`(0.1.2-alpha.4 起的按需读 API,前身 `Session.events` 属性已删)、冷读 `sessionPersistence.inspect`,服务端 `session/end-seed` 切割 + `afterSeq` 增量);`ctx.connection` 镜像与 inject 已删。设计见 [docs/plans/2026-08-20-sidechat-tab-design.md](docs/plans/2026-08-20-sidechat-tab-design.md) §10。 +4. **侧边对话转录走自有路由,不碰客户端宿主 RPC**:`ctx.connection.api`(含 `sessions.history`)在 alpha.1 整体移除,继任 `session/follow|page` 又对 `origin:'subagent'` 会话强制 subagent 地址(普通 `{kind:'session'}` 被 `agent-busy` 拒)且分页 `throughSeq` 不得超当前游标。转录因此由 **`sidechat.events`** 插件路由供给(`src/sidechat-routes.ts`:live 读 `agent.session.snapshotEvents()`、冷读 `sessionPersistence.inspect`,服务端 `session/end-seed` 切割 + `afterSeq` 增量);`ctx.connection` 镜像与 inject 已删。**0.1.5 起实时增量不在日志里**:`assistant/chunk` 事件被删除,进行中的模型增量改由 `agent/assistant-stream` 瞬时帧发布(`start` / `chunk` / `end`,不入会话日志),结算时才落 `assistant/message`(内嵌 `stream`)或 `assistant/attempt`(失败尝试,内嵌 `stream`)。插件在宿主侧 `src/assistant-live.ts` 折叠这些帧成有界缓冲,`sidechat.events` 的 `live` 字段按「当前 attempt 全量、每次轮询替换」下发(不是增量),转录映射与继承快照都读它。设计见 [docs/plans/2026-08-20-sidechat-tab-design.md](docs/plans/2026-08-20-sidechat-tab-design.md) §10。 5. **`dsh-settings` 无运行时 `settingsNamespace`**:命名空间合法性校验转为编译期模板字面量 `SettingsNamespaceInput`(小写字母开头 + `[a-z0-9-]` 尾部),`'dsh-better-sidebar'` 字面量直接过——宿主侧直接传常量(`src/index.ts` 的 settings inject)。 6. **`dsh-subagent` 的 `SUBAGENT_DESCRIPTOR_VERSION` 2 → 3**:sidechat 种子的 `subagent/descriptor` 版本由宿主包盖章,插件不硬编码;测试断言跟随常量(`tests/sidechat-routes.spec.ts`),勿钉字面量。 7. **`@deepseek-ai/dsh-client-runtime` 包已消亡**(继任 seed 是裸名 `dsh-client-store`,无 `/client` 子路径):peerDependencies、devDependencies、`dsh.client.inject`、chunk externals 白名单(`src/client/chunk-loader.ts` / `tsdown.config.ts` / `tests/chunk-loader.spec.ts` / `tests/manifest-consistency.spec.ts` 四处同步)均已无该条目。 8. **e2e scratch profile 的 `minimumReleaseAgeExclude` 含 `'@deepseek-ai/*'`**(`scripts/e2e-mount.sh` / `e2e-aggregate-mount.sh`,与仓库根 `pnpm-workspace.yaml` 同策):alpha 版本常在发布后 24h 内跑 lane,pnpm 11 的 `minimumReleaseAge` 默认会拒装新鲜包。 -9. **插件开发树的 dsh-* 传递 peer 需提升为 devDependencies**(alpha.3 首见):`dsh-subagent` 等 npm 包把 `dsh-attachment` 等 dsh-* 姊妹包全部声明为 peerDependencies(由宿主 bundle 树统一提供,宿主侧无此问题),插件仓库若只直接依赖其中一部分,其余 peer 在 pnpm 下会解析到树上残留的旧版——如 `dsh-attachment@0.1.1-rc.1` 缺 `admitPromptContent` 导出、`dsh-subagent` 产物 import 它时测试加载即崩。因此 devDependencies 需涵盖 dev 树实际触达的全部 peer(attachment / code-runtime / scope / session-projection / system-prompt / user-approval / util-time 七个即为此提升,与直接依赖同款精确钉版);`@deepseek-ai/cordis` peer 自 alpha.3 起要求 `^4.0.2`(上游全线 peer 已升)。适配新 alpha 版本时先跑 `pnpm peers check`,把新失配的传递 peer 一并提升进 devDependencies。例外:**`dsh-client-locale` 的 peer/devDep 允许落后于基线**(alpha.5 时上游停在 0.1.2-alpha.3 未发新版)——peer 下限 `^0.1.2-alpha.3` 天然容纳同 tuple 的 alpha.5 运行时,此时保持旧钉版并在 `pnpm-workspace.yaml` 注明,待上游发版再追平;rc.1 起上游恢复发版,peer/devDep 已与基线一并钉 `0.1.2-rc.1`,该例外消除。 -10. **聊天文件打开漏斗是 `remote.session.openWorkspacePath`**(`ctx.workspaces.openPath` 已随 alpha 删除,`IWorkspaces` 无此方法):「聊天区文件在侧边栏打开」拦截在 `ctx.inject(['remote.session'], …)` 内以 **defineProperty 数据属性遮蔽**该命名空间方法(gateway client 的方法是 accessor 属性、异步挂载、contribution 重挂载时服务重建——inject 回调重跑即自愈);实现见 `src/client/openpath-intercept.ts`,设计见 [docs/plans/2026-08-31-openpath-intercept-alpha-design.md](docs/plans/2026-08-31-openpath-intercept-alpha-design.md)。 -11. **自定义种子必须带 fork 标记对(`meta.isSeeded: true` + `inheritedEventCount`)**:dsh-session 契约「只给 seed 不标 isSeeded,种子算重放历史而非继承前缀」——缺标记时 `Session.ownEvents()` 含整个种子,子会话 `Inbox` 构造器重放种子里的 `agent/inbox/spliced`,**继承父会话切割时刻未领取的 inbox 输入**(排队用户消息 next-turn、长回合中工具结果上下文/steering next-step——后者即「上下文很长时侧边对话先把之前的 User msg 发出去」的根因,幽灵消息排在 boundary 之前最先发给模型)。宿主 `session.fork`(api-session-controller)的调用即规范形态;回归由 `tests/sidechat-seed-validation.spec.ts`(真实 `Session.create` + `Inbox`)守护。0.1.2-rc.1 复评:无更优雅的 sidechat 宿主 API(`ContinuableStartSpec` 仍无 seed 字段),`AgentRegistry.create` 接缝补齐标记即为规范用法。 +9. **插件开发树的 dsh-* 传递 peer 需提升为 devDependencies**(alpha.3 首见):`dsh-subagent` 等 npm 包把 `dsh-attachment` 等 dsh-* 姊妹包全部声明为 peerDependencies(由宿主 bundle 树统一提供,宿主侧无此问题),插件仓库若只直接依赖其中一部分,其余 peer 在 pnpm 下会解析到树上残留的旧版——如 `dsh-attachment@0.1.1-rc.1` 缺 `admitPromptContent` 导出、`dsh-subagent` 产物 import 它时测试加载即崩。因此 devDependencies 需涵盖 dev 树实际触达的全部 peer(attachment / code-runtime / scope / session-projection / system-prompt / user-approval / util-time 七个即为此提升,与直接依赖同款精确钉版);`@deepseek-ai/cordis` peer 自 alpha.3 起要求 `^4.0.2`(上游全线 peer 已升)。适配新 alpha 版本时先跑 `pnpm peers check`,把新失配的传递 peer 一并提升进 devDependencies。例外:**`dsh-client-locale` 的 peer/devDep 允许落后于基线**(alpha.5 时上游停在 0.1.2-alpha.3 未发新版)——peer 下限 `^0.1.2-alpha.3` 天然容纳同 tuple 的 alpha.5 运行时,此时保持旧钉版并在 `pnpm-workspace.yaml` 注明,待上游发版再追平;rc.1 起上游恢复发版,该包回到与 DSH 同 tuple(0.1.2-rc.1 → 0.1.5-rc.1,peer 下限 `^0.1.5-rc.1`),该例外消除。**alpha.2 起还有一类:上游包自身的 `dependencies` 丢失**——`@deepseek-ai/dsh-client-ui-primitives@0.1.5-alpha.2` 的 manifest 不再声明任何 `dependencies`(alpha.1 声明了 19 个),但 `lib/*.js` 仍裸 import `anser` / `shiki` / `@shikijs/langs/*` / `mdast-util-*` / `micromark-*` / `katex`。宿主由预构建前端 bundle 满足,独立安装的 dev/test 树不会——vitest 一碰 primitives 就 `Cannot find package 'anser'`。此时把 alpha.1 声明的同一组版本提升进 devDependencies(`clsx` 已在 dependencies 无需重复)。**rc.1 复查:`@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1` 仍不声明任何 `dependencies`,bundle 仍裸 import 同一组包**——这组提升不得回退,`pnpm peers check` / 单测一旦报 `Cannot find package 'anser'` 即是有人回退了。 +10. **右侧栏是 DSH 原生栏,插件只提供 tab 类型**:聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)统一走 `ctx.sidebarRight.openResource(fileAddressFor(sessionId, cwd, path))`(`packages/client/ui-chat/src/client/apply.ts` 是唯一调用点),`remote.session.openWorkspacePath` 在 0.1.5 客户端已无调用者,插件的 openpath 拦截随之删除。插件的每个 `TabDescriptor` 注册成原生 tab 类型(`kind = descriptor.id`,`extension` 带)+ 原生 tab 体(`sidebar.right.pane.tab` keyed 槽,key = `dsh-better-sidebar:`);`editor` 类型同时认领 `dsh-resource://file/**`(压过内置 `text` 的 `fallback`),并接管内置 `files` 页面 kind(`openTab('files')` 打开插件文件树,注销即复位)。`ctx.sidebarRight.openTab/openResource/close` 只对**在屏会话**写入;跨会话用具体类上的 `openTabIn/openResourceIn/closeIn`(不在 `ISidebarRight` 接口里,需结构化探测),目标会话未挂载时排队到上屏重放(`src/client/native/surface.ts`)。原生 tab 的插件侧状态(合成 `SidebarTab`、树展开集合、实例编号)在 `src/client/native/tab-adapter.tsx`;原生布局只在内存,不做持久化。**注册必须等服务、不能等槽声明**:原生栏先声明 `sidebar.right.pane.tab` 再 `provide('sidebarRightTabs')`,真机 profile(web,0.1.5-alpha.1)上槽声明回调里 `ctx.get('sidebarRightTabs')` 仍是 undefined(实测 3 秒后才出现),按槽触发注册会静默什么都不注册且永不重试——用 `ctx.inject(['sidebarRightTabs'], cb)` 驱动类型注册(`tests/native-surface.spec.ts` 以「槽先触发、服务后到达」的顺序守护),槽声明只用来挂 tab 体。指南 §0 列了全部行为差异。**alpha.2 起(v0.19.0-alpha.1)**:指南条目一度改成「图标+标题」胶囊、`TabDescriptor.description` 随之下线,新建标签页的默认页改为从注册表选(恰好 1 个指南条目 → 直接开它,0 或 ≥2 → 开指南),`revealIfOpened` 对「页面」在同一 pane 内强制去重。原生 tab 体宿主 `.paneBody` 是**有确定高度的块级滚动容器**(非 flex 容器),native 适配层因此给每个 tab 体包一层 `height:100%` 的列 flex 宿主(`sidebar.module.css` 的 `.nativeTabHost`),tab 组件根继续用 `flex:1`/`height:100%`——否则根盒塌成内容高度,sidechat 的输入框就贴不到面板底。全局面板(根级 `main` keyed 槽 + `sidebar.panellist` + `ctx.layout.selectPanel/beginNavigation`、`rightbar` 改根级并新增 `rightbar.session`)插件**不接入**,只做兼容。底部工作台的中心列锚点改认 `[data-slot="main.conversation"]`(并跳过 `display: contents` 祖先,同时保留 alpha.1 的 `[data-slot="conversation"]`);文件地址语法跟随 alpha.2(`fileAddressFor` 一律 session 作用域、绝对路径保留前导 `/`、`parseFileAddress` 前缀解析并去 `?`/`#`)。**rc.1 起(v0.19.0)**:`SidebarRightGuideEntry.description` 回归(可选),插件恢复 `TabDescriptor.description`(六个内置类型各声明一条说明,`guideDesc*` 词条回到 20 份词典),但**宿主的原生指南只在列出的条目 ≤ 4 条时渲染说明**(上游 `MAX_DESCRIBED_ENTRIES = 4`;更长的列表是整列丢弃,不是截断)——插件默认贡献 6 个 guide 条目(文件 / 文件变动 / 任务管理 / 侧边对话 / 终端 / 浏览器),所以**默认组合下说明不渲染**,只有读者在插件设置页关掉足够多 tab 类型、把 guide 压到 ≤ 4 条时才出现;插件**不恢复**旧的 `nativeGuideDesc` 通用兜底句(宿主自己没有兜底,通用句是噪音),没声明说明的 descriptor 就不发 `description` 字段;条目缺 `icon` 时由宿主补方块占位。 +11. **自定义种子必须带 fork 标记对(`meta.isSeeded: true` + `inheritedEventCount`)**:dsh-session 契约「只给 seed 不标 isSeeded,种子算重放历史而非继承前缀」——缺标记时 `Session.ownEvents()` 含整个种子,子会话的 inbox 折(`inboxProjectionDefinition`,0.1.5 起 `@deepseek-ai/dsh-agent` 只导出 `Inbox` 接口,实现是该投影)重放种子里的 `agent/inbox/spliced`,**继承父会话切割时刻未领取的 inbox 输入**(排队用户消息 next-turn、长回合中工具结果上下文/steering next-step——后者即「上下文很长时侧边对话先把之前的 User msg 发出去」的根因,幽灵消息排在 boundary 之前最先发给模型)。宿主 `session.fork`(api-session-controller)的调用即规范形态;回归由 `tests/sidechat-seed-validation.spec.ts`(真实 `Session.create` + 对 `ownEvents()` 跑 inbox 折)守护。0.1.5 复评:无更优雅的 sidechat 宿主 API(`ContinuableStartSpec` 仍无 seed 字段),`AgentRegistry.create` 接缝补齐标记即为规范用法;`Session.create` 的 header `version` 必须用 `SESSION_FORMAT_VERSION`(0.1.5 是 `3` 字面量类型),勿钉 `0`。 --- @@ -67,6 +67,6 @@ ## 6. 文档与测试地图 -- **接入 API 全参考**:[docs/external-plugin-guide.md](docs/external-plugin-guide.md)(消费插件开发者向;§4 Tab API / §5 FileViewer API / §7 服务方法 / §10 平台陷阱 / §11 自由窗口 / §12 皮肤契约 / §15 真实案例)。 +- **接入 API 全参考**:[docs/external-plugin-guide.md](docs/external-plugin-guide.md)(消费插件开发者向;§0 原生栏承载面 / §4 Tab API / §5 FileViewer API / §7 服务方法 / §10 平台陷阱 / §11 已移除的自由窗口 / §12 皮肤契约 / §15 真实案例)。 - **设计文档**:[docs/plans/](docs/plans/)(30+ 份逐特性设计,含实施偏差记录)。 -- **关键测试守护**:`tests/service.spec.ts` / `builtins.spec.ts`(注册表与内置清单:7 tab + 6 viewer)/ `market-manifest.spec.ts`(市场约束)/ `e2e-host-protocol.spec.ts`(RPC 双协议)/ `free-window.spec.tsx`(自由窗口)/ `theme.spec.ts`(皮肤契约)/ `plugin-list.spec.ts`(推荐插件目录)/ `fs-search.spec.ts`(host 文件名搜索)。 +- **关键测试守护**:`tests/service.spec.ts` / `builtins.spec.ts`(注册表与内置清单:7 tab + 6 viewer)/ `market-manifest.spec.ts`(市场约束)/ `e2e-host-protocol.spec.ts`(RPC 双协议)/ `native-surface.spec.ts`(原生右侧栏承载面)/ `theme.spec.ts`(皮肤契约)/ `plugin-list.spec.ts`(推荐插件目录)/ `fs-search.spec.ts`(host 文件名搜索)。 diff --git a/README.md b/README.md index 95f54e4..24fe9f8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # dsh-better-sidebar +> [!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)。 +
一个服务化的侧边栏框架,一套开箱即用的完整工作台

@@ -9,7 +12,7 @@ GitHub stars License: MIT dshfind

- 支持的 DSH 版本(v0.18.1 正式版):0.1.2-rc.1+ + 支持的 DSH 版本(v0.19.0 正式版):0.1.5-rc.1+ 插件生态:GitHub topic dsh-better-sidebar

文件管理 编辑预览 内嵌浏览器 真实终端 文件变动 后台任务 侧边对话 插件接入

右侧栏 + 底部面板双工作台,并把 ctx.betterSidebar 服务开放给所有插件——
@@ -44,11 +47,10 @@ - **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整) - **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具 - **📂 模型侧边栏打开(可选)**:全局设置开启后注入 `sidebar_open` 工具——模型可主动在侧边栏打开文件 / 文件夹(树以该目录为根)/ HTTP(S) 网页 -- **🌿 文件变动**:Git 视角(真 diff / 历史 / 暂存·提交·还原 / worktree·子仓库选择)与本轮文件视角(模型读 / 写 / 编辑实时追踪,按文件分组、按类型筛选)**双视角合一**;统一 diff 渲染(改蓝配对 + 行内字符级高亮 + 语法着色(含 mjs/cjs/mts/cts、CSS/SCSS/Less、HTML/XML/SVG/Vue、GraphQL、JSONC/JSON5)+ 上下文折叠),底部可拖拽预览面板,可一键展开为独立 diff tab(默认自由浮窗,可在设置改为面板下半 split);`.md` 操作(读 / 写 / 编辑)预览头部可切换**阅读模式**——经共享 MarkdownText 渲染 GFM 表格 / 任务列表 / 删除线 / 脚注 / 数学公式,本地图片自动改写为 `/sidebar/file` 媒体路由;含 ```mermaid 围栏时走编辑器同款懒加载 mermaid 渲染器(图可点击缩放 / 平移);**敏感内容脱敏**——凭据形态路径整文件遮罩、普通文件按内容形态遮值(api_key: / Bearer / sk- / AKIA / ghp_ / PEM 等,字段名保留),默认开启、预览面板一键开关(localStorage 记忆),仅影响显示、不改会话数据。已知边界:mermaid 无引号节点标签含被遮密钥时,图回退源码(规避:标签加引号);`.html` 操作(读 / 写 / 编辑)预览头部可切换**渲染模式**——复用编辑器同款 `/sidebar/html` 路由 iframe,相对资源(./style.css、img/x.png)同路由解析,分段读取也渲染完整文档,恒定沙箱(opaque origin + CSP 头,无逃生门);`.pdf` 操作(读 / 写 / 编辑)同样可切换**渲染模式**——复用编辑器同款 PDF 预览(媒体路由字节流 + 显式 Blob,浏览器原生查看器内嵌,附下载入口) +- **🌿 文件变动**:Git 视角(真 diff / 历史 / 暂存·提交·还原 / worktree·子仓库选择)与本轮文件视角(模型读 / 写 / 编辑实时追踪,按文件分组、按类型筛选)**双视角合一**;统一 diff 渲染(改蓝配对 + 行内字符级高亮 + 语法着色(含 mjs/cjs/mts/cts、CSS/SCSS/Less、HTML/XML/SVG/Vue、GraphQL、JSONC/JSON5)+ 上下文折叠),底部可拖拽预览面板,可一键展开为独立 diff tab(落进工作台的 diff 分栏);`.md` 操作(读 / 写 / 编辑)预览头部可切换**阅读模式**——经共享 MarkdownText 渲染 GFM 表格 / 任务列表 / 删除线 / 脚注 / 数学公式,本地图片自动改写为 `/sidebar/file` 媒体路由;含 ```mermaid 围栏时走编辑器同款懒加载 mermaid 渲染器(图可点击缩放 / 平移);**敏感内容脱敏**——凭据形态路径整文件遮罩、普通文件按内容形态遮值(api_key: / Bearer / sk- / AKIA / ghp_ / PEM 等,字段名保留),默认开启、预览面板一键开关(localStorage 记忆),仅影响显示、不改会话数据。已知边界:mermaid 无引号节点标签含被遮密钥时,图回退源码(规避:标签加引号);`.html` 操作(读 / 写 / 编辑)预览头部可切换**渲染模式**——复用编辑器同款 `/sidebar/html` 路由 iframe,相对资源(./style.css、img/x.png)同路由解析,分段读取也渲染完整文档,恒定沙箱(opaque origin + CSP 头,无逃生门);`.pdf` 操作(读 / 写 / 编辑)同样可切换**渲染模式**——复用编辑器同款 PDF 预览(媒体路由字节流 + 显式 Blob,浏览器原生查看器内嵌,附下载入口) - **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止) - **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话 -- **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉 -- **🪟 自由窗口**:把标签栏的任一 tab 拖到主会话区域——成为可移动 / 缩放 / 置顶的悬浮窗口(默认 390×780),拖回侧边栏 pane 即停靠,随会话持久化;`features` 含 `'floatWindows'`,插件 tab 无差别支持 +- **🖥️ 原生右侧栏 + 底部工作台**:右列交给 DSH 0.1.5 的原生右侧栏——插件把每个 tab 类型注册成原生 tab(文件打开走 `dsh-resource://file/**`,并接管内置「文件」页 / 文件树),插件自己只保留底部工作台(分栏 / 终端 / 随会话持久化),开合按钮挂在会话头右侧 - **📌 固定终端**:右键终端 Tab 可「固定到工作区 / 固定到全局」——固定后切换会话不消失,在 TabBar 内联呈现(跨会话虚拟 Tab,点击就地激活,PTY 按 home 会话 id+tab 直连宿主 PTY,无需切回宿主会话);Agent 终端被 reconcile 移除时豁免保留 - **🔁 会话隔离**:布局 / Tab / 面板按会话持久化,陈旧状态自动净化 - **⚙️ 声明式设置**:设置页「侧边卡片」逐项独立开关,二级设置经齿轮弹窗 @@ -62,9 +64,9 @@ **前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。 **支持的 DSH 版本**: -支持的 DSH 版本(v0.18.1 正式版):0.1.2-rc.1+ +支持的 DSH 版本(v0.19.0 正式版):0.1.5-rc.1+ -> 📌 **正式版**:`v0.18.0` 起适配 DSH **0.1.2-rc.1+**(npm dist-tag `latest`),不再支持 0.1.0-rc.8 ~ 0.1.1-rc.2——DSH stable(≤ 0.1.1-rc.2)用户请固定安装 `dsh-better-sidebar@0.17.1`(`@latest` 已由 v0.18.0 接管);停留在 0.1.2-alpha.x 的宿主请先升级 DSH,或继续用 `dsh-better-sidebar@alpha`(v0.18.0-alpha.0)。 +> 📌 **正式版**:`v0.19.0` 起适配 DSH **0.1.5-rc.1+**(npm dist-tag `latest`)。仍停在 DSH 0.1.5-alpha.2 的用户请固定安装 `dsh-better-sidebar@0.19.0-alpha.1`;0.1.2-rc.1 稳定线用户继续用 `dsh-better-sidebar@0.18.x`;DSH ≤ 0.1.1-rc.2 请用 `dsh-better-sidebar@0.17.1`。 ```sh dsh plugin --profile web add dsh-better-sidebar@latest # 首次会因 pnpm 11 拦截 node-pty 构建脚本而失败(依赖已写入) @@ -166,7 +168,7 @@ dsh registry enable dsh-external/dsh-better-sidebar | **🖥️ CodeMirror 代码编辑器**
CodeMirror 代码编辑器
| **🖼️ 图片内联预览**
图片内联预览
| | **💻 真实终端**
xterm.js + node-pty 真实 shell(不是模拟器):断线重连 transcript 回放、shell / shellArgs 可配置(设置页或 `cordis.patch.yml`)、可选为模型注入 `terminal_*` 工具(agent 可直接开终端跑命令)。
真实终端
| **🌿 文件变动:Git 视角 + 本轮文件**
双视角合一:**Git 视角**保留完整源代码管理(暂存 / 取消暂存 / 提交(`Ctrl+Enter`)/ 还原、历史、worktree 与子仓库选择);**本轮文件视角**实时折叠会话事件日志,记录模型读 / 写 / 编辑的每个文件(按文件分组、按类型筛选、操作数角标)。点击任意改动在底部**可拖拽预览面板**查看统一 diff——删红 / 增绿 / 改蓝配对 + 行内字符级高亮 + 语法着色 + 上下文折叠——也可一键展开为 VSCode 式独立 diff tab(同一渲染栈)。
文件变动
| | **🌐 内嵌浏览器**
多开网页 tab:后退 / 前进 / 刷新 / 地址栏;内容运行在**不透明源沙箱 iframe**(界面实时显示沙箱状态,可按页面临时解锁);聊天里的外链点击可被接管到侧边栏打开(按协议分流,可配)。
内嵌浏览器
| **🧩 任务页:子代理拓扑 + 后台任务**
子代理树实时拓扑(运行状态、批量实时预览)+ 后台任务清单(退出码 / 实时输出 / 强制终止);新子代理 / 新任务可自动激活任务页,宽屏同时展开侧边栏,窄屏不强制展开全屏抽屉(可关)。
任务页:子代理拓扑
| -| **💬 侧边对话(beta)**
Codex 风格侧边线程:**每个对话一个独立 Tab**;线程继承主会话完整上下文(含进行中回合,以 interrupted 诚实冻结)独立运行,不污染主会话;可持续追问、重启冷恢复;一键「保存为新会话」提升为顶层会话。
侧边对话(beta)
| **🪟 双工作台:右侧栏 + 底部面板 + 分栏**
右侧栏与底部面板可同时展开;拖 Tab 到分栏边缘**拆分**、拖到中间**合并**(可跨面板);面板宽高左缘/上缘拖拽调节;移动端自动合并为全宽抽屉;把 tab 拖到主会话区域可变为**自由窗口**(悬浮 / 缩放 / 置顶,拖回 pane 停靠)。
双工作台(右侧栏 + 底部面板)
| +| **💬 侧边对话(beta)**
Codex 风格侧边线程:**每个对话一个独立 Tab**;线程继承主会话完整上下文(含进行中回合,以 interrupted 诚实冻结)独立运行,不污染主会话;可持续追问、重启冷恢复;一键「保存为新会话」提升为顶层会话。
侧边对话(beta)
| **🖥️ DSH 原生右侧栏 + 插件底部工作台**
右列是 DSH 自己的右侧栏:插件把每个 tab 类型注册成原生 tab(含接管内置「文件」页与文件预览),聊天里的文件点击直接落到原生栏;插件自有底部面板可与其同时展开,拖 Tab 到分栏边缘**拆分**、拖到中间**合并**,高度拖上缘调节;开合按钮在会话头右侧。
双工作台(右侧栏 + 底部面板)
| | **⚙️ 声明式设置**
设置页「侧边卡片」分区:每个 tab / 预览器一张小卡片,独立开关(高亮启用态 + 品牌开关滑块);二级设置经卡片底部「功能设置」条弹窗(开关 / 文本 / 数字 / 下拉);插件自有设置持久化在 `pluginSettings`。
声明式设置:侧边卡片
| **📱 移动端**
窄屏(<768px)自动切换为全宽抽屉:底栏 tab 一次性并入右侧栏,触屏拖拽可调。
移动端全宽抽屉
| ## 🌐 插件生态 @@ -261,7 +263,62 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar ## 🆕 最近更新 -**支持的 DSH 版本**:支持的 DSH 版本(v0.18.1 正式版):0.1.2-rc.1+ · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) +**支持的 DSH 版本**:支持的 DSH 版本(v0.19.0 正式版):0.1.5-rc.1+ · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) + +### v0.19.0 + +> 📌 **正式版**(npm `latest`,无 prerelease 后缀):本版仅支持 **DSH 0.1.5-rc.1+**(peer 下限 `^0.1.5-rc.1`,CI 钉 `@deepseek-ai/dsh@0.1.5-rc.1`)。DSH 0.1.5-alpha.2 用户请继续用 **v0.19.0-alpha.1**(npm `alpha` 标签仍指向它);0.1.2-rc.1 稳定线继续用 **v0.18.1**。 + +**✨ 新功能** + +- 📝 **新建标签页列表恢复可选说明**(#613):DSH 0.1.5-rc.1 让 `SidebarRightGuideEntry.description` 回归(可选),插件随之恢复 `TabDescriptor.description`,六个内置类型各写回一条说明(原生指南的 文件 / 文件变动 / 任务管理 / 侧边对话 / 终端 / 浏览器 六行都带上它),`guideDesc*` 词条回到 20 份词典。**宿主的原生指南只在列出的条目 ≤ 4 条时渲染说明**(更长的列表是整列丢弃,不是截断),而插件默认贡献 6 个 guide 条目——因此默认组合下说明不渲染,只有在插件设置页关掉足够多的 tab 类型、把 guide 压到 ≤ 4 条时才会出现。未声明说明的条目仍是「图标 + 标题」单行(插件不补通用兜底句)。 + +**🐛 修复** + +- 无。rc.1 相对 alpha.2 的 delta 很小(373 个变更文件,绝大多数是上游各包版本号、原生右侧栏预览 UI 打磨与测试快照),除上述说明字段回归(并附带上游 `files` 类型改用自己的彩色文件夹图标作指南字形)外,没有需要插件适配的变更。 + +**🧰 CI 与内部** + +- 基线推进到 **DSH 0.1.5-rc.1+**(#613):peer 下限、devDependency 钉版、CI 挂载车道与 `dsh.plugin.json` 的 `engines.dsh` 同步(rc.1 在 npm 上同时是 `latest` 与 `next`)。 +- 明确未变、不必再核:全局主面板模型(`main` 槽 / `sidebar.panellist` / `ctx.layout` / 根级 `rightbar` + `rightbar.session`,插件仍不接入)、文件地址语法(`packages/util/workspace-path` 只动了版本号)、原生 tab 体宿主契约(`.paneBody` 仍是有确定高度的块级滚动容器)、core / agent / session / subagent 宿主 API 与 `ui-primitives` 导出面(仅 CodeBlock 渲染变化)。 +- `@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1` 仍不声明 `dependencies` 而 bundle 仍裸 import `anser` / `shiki` / `@shikijs/langs/*` / `mdast-util-*` / `micromark-*` / `katex`——上一版提升进 devDependencies 的那组包因此保留,不得回退。 +- 真机验证(DSH 0.1.5-rc.1 + 插件 0.19.0):门禁 `typecheck` / `lint` / `check:consumer-types` 全绿,单测 **124 files · 1296 passed · 9 skipped**,`pnpm peers check` 干净;挂载冒烟对真实 rc.1 **7 passed**(含 tab 体填充断言,以及新增的「指南 ≤4 条时说明才渲染」断言);本地 3080 实测:guide 六行仍是「图标 + 标题」(6 > 4,说明按上游规则不渲染;上游把胶囊 `min-height` 从 48px 调到 56px),文件树点击 `AGENTS.md` 落到插件编辑器(CodeMirror 就绪),sidechat 输入框贴底(宿主盒 962px == 面板体 962px,composer 底边距 8px),底部工作台与中心列左右边完全重合,`pageerror` 0(控制台仅有第三方 `dsh-tauri-worktree` 的 `/api/dsh-worktree/attach` 500,与本次改动无关)。 + +### v0.19.0-alpha.1 + +> 🧪 **alpha 通道**(npm dist-tag `alpha`,安装 `dsh-better-sidebar@alpha`):本版仅支持 **DSH 0.1.5-alpha.2+**(peer 下限 `^0.1.5-alpha.2`,CI 钉 `@deepseek-ai/dsh@0.1.5-alpha.2`)。0.1.5-alpha.1 请继续用 **v0.19.0-alpha.0**;0.1.2-rc.1 稳定线用 **v0.18.1**(npm `latest`)。 + +**✨ 新功能** + +- 🪟 **每个 tab 体都填满面板**(#609):原生右侧栏的 tab 体宿主是「有确定高度的块级滚动容器」而非 flex 容器,此前插件各 tab 的根只写 `flex: 1`,在块容器里塌成内容高度——侧边对话的输入框因此贴不到面板底(转录一长就被推出可视区)。现在 native 适配层统一给每个 tab 体包一层 `height: 100%` 的列 flex 宿主,插件全部 tab(含第三方 `registerTab` 注册的 descriptor)恢复与底部工作台一致的填满语义。 + +**🐛 修复** + +- 🧭 **跟随 DSH 0.1.5-alpha.2 的文件地址语法**:`fileAddressFor` 一律产出 session 作用域地址、绝对路径保留前导 `/`;`parseFileAddress` 改为前缀解析并忽略 `?`/`#` 后缀。 +- 🪟 **底部工作台的中心列定位跟随 alpha.2 全局面板改动**:`conversation` 槽改为根级 `main` keyed 槽下的 `main.conversation`,定位器改认新 key 并跳过 `display: contents` 槽宿主(alpha.1 的旧 key 仍兼容)。 + +**🧰 CI 与内部** + +- 基线推进到 DSH 0.1.5-alpha.2(#609):peer 下限、22 个 devDependency 钉版、CI 挂载车道与 `dsh.plugin.json` engines 同步;`pnpm peers check` 干净(按 §3-9 补提 `dsh-session-persistence` 传递 peer)。 +- **移除 `TabDescriptor.description`**:alpha.2 的原生指南条目不再渲染第二行(改为「图标+标题」胶囊),该字段与 6 个 `guideDesc*` 词条(20 份词典)一并下线;新建标签页的默认页改由注册表选(恰好 1 个指南条目则直接打开它)。 + +### v0.19.0-alpha.0 + +> 🧪 **alpha 通道**(npm dist-tag `alpha`,安装 `dsh-better-sidebar@alpha`):本版仅支持 **DSH 0.1.5-alpha.1+**(peer 下限 `^0.1.5-alpha.1`,CI 钉 `@deepseek-ai/dsh@0.1.5-alpha.1`)。0.1.2-rc.1 稳定线请继续用 **v0.18.1**(npm `latest`)。 + +**✨ 新功能** + +- 🖥️ **接入 DSH 原生右侧栏**(#604):右列改为 DSH 自己的右侧栏——插件的 7 个 tab 类型全部注册成原生 tab 类型 + 原生 tab 体;聊天里的文件打开统一走 `ctx.sidebarRight.openResource(dsh-resource://file/…)`;`editor` 类型以 `extension` 优先级认领文件资源(压过内置文本预览)并接管内置「文件」页 kind(注销即复位);跨会话打开在目标会话未上屏时排队重放。 +- 🧩 **退役插件自绘右侧面板与自由窗口**(#605):右列归 DSH 后,插件只保留底部工作台(单分栏树、随会话持久化),开合按钮注册进 DSH 会话头 utilities 槽;浮窗 API(`floats` / `floatTab` / `dockFloat` / `raiseFloat` / 右键「移动到自由窗口」)、`features` 里的 `'floatWindows'`,以及 `openByDefault` / `defaultWidthPercent` / `changesDiffFloat` 三个设置项一并删除(旧持久化文档里的 `floats` 字段被忽略,不影响加载)。 +- 🔗 **适配 DSH 0.1.5 宿主契约**(#603):`assistant/chunk` 事件删除 → 实时增量改由 `agent/assistant-stream` 帧折叠(侧边对话转录的 `live` 字段);`sessionPersistence.inspect` 删除 → 冷会话读取改走 `open(id,'read')`;会话头 `version` 用 `SESSION_FORMAT_VERSION`。 + +**🐛 修复** + +- 侧边对话转录 / `jobs.output` 回放 / fork 继承等 8 处会话事件读取跟随 0.1.5 契约;自定义种子补齐 fork 标记对,避免继承父会话未领取的 inbox 输入。 + +**🧰 CI 与内部** + +- 真机挂载冒烟门禁钉 0.1.5-alpha.1,e2e 增加「展开原生栏 → 经引导页逐个打开插件 tab 类型」的巡检;typecheck / lint / 单测 / 挂载车道全绿。 ### v0.18.1 @@ -316,9 +373,9 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
历史版本(v0.12.0 – v0.15.2) -### v0.19.0-alpha.0 +### v0.19.0-alpha.0(0.1.2-alpha.5 适配,未发布,内容并入 v0.18.0) -> 🧪 **alpha 通道**:本版仅支持 **DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.5`,npm dist-tag `alpha`,安装 `dsh-better-sidebar@alpha`)。该版本号未单独发布,内容已并入 **v0.18.0** 正式版。 +> 🧪 **alpha 通道**:本版仅支持 **DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.5`,npm dist-tag `alpha`)。该版本号当时未单独发布,内容已并入 **v0.18.0** 正式版;v0.19.0-alpha.0 这个号后来被 0.1.5 适配线复用(见上)。 - 🔗 **适配 DSH 0.1.2-alpha.5(npm 已发布,`alpha` dist-tag)**:CI 挂载门禁钉版、`dsh.plugin.json` engines 下限与 `@deepseek-ai/*` peer / devDependencies 基线升至 0.1.2-alpha.5(真机挂载冒烟 14/14 验证)。`dsh-client-locale` 上游停在 0.1.2-alpha.3 未发新版,其 peer 下限 / devDep 保持并天然兼容 alpha.5 运行时(`pnpm peers check` 零失配,无需新增提升传递 peer)。代码适配了 alpha.4 的兼容性标记改动——`Session.events` 属性移除,迁移到按需读 API `snapshotEvents()`(sidechat 转录 live 读、fork 继承、`jobs.output` 回放、subagent 活跃度共 8 处),新建线程 meta 中宿主已删的 `seedLength` 一并移除;alpha.4 其余变化(双向 `send_message`、自定义模型发现复用 Profile 请求头、`SessionSeq`/`SessionLogOffset` 强类型)与 alpha.5(升级启动修复)经核实不触及本插件其余表面。 @@ -526,7 +583,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 8 tab + 6 viewer 亦通过同一服务注册)。v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等)。 -完整接入文档(全字段、匹配算法、HMR 陷阱、声明式设置、版本探测、自由窗口与皮肤契约):**[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**;仓库开发规则(硬约束 / CI / 发版)见 [`AGENTS.md`](./AGENTS.md)。 +完整接入文档(全字段、匹配算法、HMR 陷阱、声明式设置、版本探测、原生栏承载面与皮肤契约):**[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**;仓库开发规则(硬约束 / CI / 发版)见 [`AGENTS.md`](./AGENTS.md)。 ### ➕ 添加插件(推荐插件目录) diff --git a/README_EN.md b/README_EN.md index f129fe3..04e5414 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,5 +1,8 @@ # dsh-better-sidebar +> [!IMPORTANT] +> **Built on DSH's native sidebar API** (since v0.19.0, DSH `0.1.5-rc.1+`): the right column *is* DSH's own sidebar — every plugin tab type and tab body registers and opens through `ctx.sidebarRightTabs` / `ctx.sidebarRight`, every file open from the chat goes through `ctx.sidebarRight.openResource('dsh-resource://file/…')`, and the plugin **no longer draws a right panel of its own** (the old free-window capability is gone with it). The self-drawn bottom workbench and the `ctx.betterSidebar` service other plugins register against are unchanged — see the [plugin integration guide](docs/external-plugin-guide.md). +
A service-oriented sidebar framework, and a complete workbench out of the box

@@ -9,7 +12,7 @@ GitHub stars License: MIT dshfind

- Supported DSH versions (v0.18.1): 0.1.2-rc.1+ + Supported DSH versions (v0.19.0): 0.1.5-rc.1+ Plugin ecosystem: GitHub topic dsh-better-sidebar

File management Edit & preview Embedded browser Real terminal Changes Background tasks Side Chat Plugin integration

A dual workbench (right sidebar + bottom panel) that opens its ctx.betterSidebar service to every plugin —
@@ -47,8 +50,7 @@ - **🌿 Changes**: one tab, two lenses — **Git** (real diff / history / stage·commit·revert / worktree & child-repo selection) and **This Session** (live tracking of every file the model reads / writes / edits, grouped by file with kind filters); a unified diff renderer (mod pairing + intra-line character highlights + syntax coloring incl. mjs/cjs/mts/cts, CSS/SCSS/Less, HTML/XML/SVG/Vue, GraphQL, JSONC/JSON5 + context folding), a draggable bottom preview pane, and one-click expansion into a dedicated diff tab; `.md` ops (read / write / edit) offer a **reading-mode** toggle in the preview header — the shared MarkdownText renders GFM tables / task lists / strikethrough / footnotes / math, with local images rewritten through the /sidebar/file media route; documents with ```mermaid fences render through the editor's lazy mermaid renderer (click-to-zoom / pan diagrams); **secret redaction** — credential-shaped paths mask whole files and ordinary files mask secret-shaped values (api_key: / Bearer / sk- / AKIA / ghp_ / PEM …, field names kept), on by default with a one-click preview-pane toggle (persisted in localStorage), display-only — session data untouched. Known edge: an unquoted mermaid label containing a redacted secret breaks the diagram (falls back to source); workaround: quote the label. `.html` ops (read / write / edit) gain a **Render** toggle in the preview head — the editor's `/sidebar/html` route iframe, with relative assets resolving inside the route and segmented reads rendering the full document; always sandboxed (opaque origin + CSP header, no escape hatch). `.pdf` ops (read / write / edit) gain the same **Render** toggle — the editor's PDF preview reused verbatim (media-route bytes + explicit Blob, the browser's native viewer inline, with a download fallback) - **🧩 Background Tasks**: agent topology + background tasks (exit codes / live output / force-kill) - **💬 Side Chat (beta)**: Codex-style side threads — the child inherits the parent's FULL context (completed turns + the pending question + the in-progress turn's assistant output and tool activity, honestly frozen as "interrupted") and runs independently without entering the main conversation; threads support continuous follow-ups (auto-resumed after a DSH restart) and one-click "Save as new session" promotion to a top-level session -- **🪟 Dual Workbench**: right sidebar + bottom panel; drag tabs to split / merge panes (cross-panel), mobile auto-merges into a full-width drawer -- **🪟 Free Windows**: drag any tab onto the main conversation area to turn it into a movable / resizable / raiseable floating window (default 390×780); drag it back onto a pane to dock; persisted per session. `features` includes `'floatWindows'` and plugin tabs are supported identically +- **🖥️ Native right sidebar + plugin bottom workbench**: the right column belongs to DSH 0.1.5's own sidebar — the plugin registers every tab type as a native tab (file opens go through `dsh-resource://file/**`, and the built-in Files page / file tree is taken over), keeping only its own bottom workbench (split panes / terminals / per-session persistence) whose toggle sits in the session header - **📌 Pinned Terminals**: right-click a terminal tab to "Pin to Workspace / Pin Globally" — pinned terminals survive session switches and surface inline in the TabBar as virtual tabs (click activates in-place, PTY connects directly to the home session's PTY via WS, no session jump needed); agent terminals exempted from reconcile removal - **🔁 Session Isolation**: layout / tabs / panels persisted per session, stale state auto-purged - **⚙️ Declarative Settings**: per-item toggles in the "Side Cards" settings section, secondary settings via the gear dialog @@ -62,9 +64,9 @@ **Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10. **Supported DSH versions**: -Supported DSH versions (v0.18.1): 0.1.2-rc.1+ +Supported DSH versions (v0.19.0): 0.1.5-rc.1+ -> 📌 **Stable release**: starting with `v0.18.0` the plugin targets DSH **0.1.2-rc.1+** (npm dist-tag `latest`) and drops 0.1.0-rc.8 ~ 0.1.1-rc.2 — stable-DSH (≤ 0.1.1-rc.2) users should stay pinned to `dsh-better-sidebar@0.17.1` (`@latest` now points at v0.18.0); hosts still on 0.1.2-alpha.x should upgrade DSH first, or keep `dsh-better-sidebar@alpha` (v0.18.0-alpha.0). +> 📌 **Stable release**: starting with `v0.19.0` the plugin targets DSH **0.1.5-rc.1+** (npm dist-tag `latest`). Hosts still on DSH 0.1.5-alpha.2 should stay pinned to `dsh-better-sidebar@0.19.0-alpha.1`; the 0.1.2-rc.1 stable line keeps using `dsh-better-sidebar@0.18.x`; DSH ≤ 0.1.1-rc.2 should use `dsh-better-sidebar@0.17.1`. ```sh dsh plugin --profile web add dsh-better-sidebar@latest # first run fails: pnpm 11 blocks node-pty build scripts (the dependency is still written) @@ -166,7 +168,7 @@ Update: `git pull && pnpm install && pnpm build` → `node scripts/package-regis | **🖥️ CodeMirror editor**
CodeMirror editor
| **🖼️ Inline image preview**
Inline image preview
| | **💻 Real Terminal**
xterm.js + node-pty real shell (not an emulator): transcript replay on reconnect, configurable shell / shellArgs (settings page or `cordis.patch.yml`), and optional `terminal_*` model tools so the agent can open terminals and run commands itself.
Real terminal
| **🌿 Changes: Git lens + This-Session lens**
Two lenses on "what changed?": the **Git lens** keeps the full source-control surface (stage / unstage / commit (`Ctrl+Enter`) / revert, history, worktree and child-repo selectors); the **This Session** lens folds the session event log live, recording every file the model read / wrote / edited (grouped by file, kind-filtered, op-count badge). Clicking any change previews it in the **draggable bottom pane** with the unified diff — del red / add green / mod-blue pairing + intra-line character highlights + syntax coloring + context folding — or expands into a VSCode-style dedicated diff tab (same rendering stack).
Changes
| | **🌐 Embedded Browser**
Multiple web tabs with back / forward / reload / address bar; content runs in an **opaque-origin sandboxed iframe** (live sandbox status in the UI, per-page temporary unlock available); external-link clicks in the chat can be taken over into the sidebar (protocol-based routing, configurable).
Embedded browser
| **🧩 Tasks: Agent Topology + Background Jobs**
Live subagent-tree topology (run states, batched live previews) plus the background-jobs list (exit codes / live output / force-kill); new subagents / jobs can auto-activate the Tasks page, expanding the sidebar on wide viewports without forcing narrow full-screen drawers open (configurable).
Tasks: subagent topology
| -| **💬 Side Chat (beta)**
Codex-style side threads: **one independent tab per conversation**; the thread inherits the parent's full context (including the in-progress turn, honestly frozen as "interrupted") and runs independently without polluting the main session; follow-ups survive restarts; one click promotes the thread to a top-level session.
Side Chat (beta)
| **🪟 Dual Workbench: Sidebar + Bottom Panel + Split Panes**
The right sidebar and the bottom panel can stay open together; drag a tab to a pane edge to **split**, to the middle to **merge** (works across panels); panel width/height drag from the left/top edge; on mobile everything merges into a full-width drawer; drag a tab onto the main conversation area to turn it into a **free window** (float / resize / raise, drag back onto a pane to dock).
Dual workbench (right sidebar + bottom panel)
| +| **💬 Side Chat (beta)**
Codex-style side threads: **one independent tab per conversation**; the thread inherits the parent's full context (including the in-progress turn, honestly frozen as "interrupted") and runs independently without polluting the main session; follow-ups survive restarts; one click promotes the thread to a top-level session.
Side Chat (beta)
| **🖥️ DSH's native right sidebar + plugin bottom workbench**
The right column is DSH's own sidebar: the plugin registers every tab type as a native tab (including the built-in Files page and file previews), so clicking a file in the chat lands there directly; the plugin's own bottom panel can stay open alongside it — drag a tab to a pane edge to **split**, to the middle to **merge**, drag the top edge to resize; the toggle lives in the session header.
Dual workbench (right sidebar + bottom panel)
| | **⚙️ Declarative Settings**
The "Side card" section in DSH settings: one small card per tab / viewer with an independent toggle (highlighted enabled state + brand switch); secondary settings open from the "Feature settings" strip at the card bottom (switch / text / number / select rows); plugin-owned settings persist under `pluginSettings`.
Declarative settings: side cards
| **📱 Mobile**
On narrow screens (<768px) the panels become a full-width drawer: bottom-panel tabs merge into the sidebar once, with touch-friendly dragging.
Mobile full-width drawer
| ## 🌐 Plugin Ecosystem @@ -265,7 +267,62 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid Service API base screenshot
-**Supported DSH versions**: Supported DSH versions (v0.18.1): 0.1.2-rc.1+ · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page +**Supported DSH versions**: Supported DSH versions (v0.19.0): 0.1.5-rc.1+ · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page + +### v0.19.0 + +> 📌 **Stable release** (npm `latest`, no prerelease suffix): supports **DSH 0.1.5-rc.1+** only (peer floor `^0.1.5-rc.1`, CI pins `@deepseek-ai/dsh@0.1.5-rc.1`). Hosts on DSH 0.1.5-alpha.2 should stay on **v0.19.0-alpha.1** (the npm `alpha` tag still points there); the 0.1.2-rc.1 stable line keeps using **v0.18.1**. + +**✨ New** + +- 📝 **The new-tab list gets an optional description back**: DSH 0.1.5-rc.1 restored `SidebarRightGuideEntry.description` (optional), so the plugin restores `TabDescriptor.description` — each of the six built-in types declares a line again (all six guide rows — Files / Changes / Tasks / Side chat / Terminal / Browser — carry one), and the `guideDesc*` keys return to all 20 dictionaries. **The host's native guide renders descriptions only while the listed entries are ≤ 4** (a longer list drops every description rather than truncating), and the plugin contributes six guide entries by default — so in the default composition no description is rendered; they appear once the reader disables enough tab types (per-type switches live on the plugin's settings page) to bring the guide down to four entries or fewer. An entry that declares none stays a single icon + title line (the plugin ships no generic fallback sentence). (#613) + +**🐛 Fixes** + +- None. The rc.1 delta over alpha.2 is small (373 changed files, overwhelmingly upstream package version bumps, native-sidebar preview UI polish, and test snapshots); apart from the restored description field (plus upstream's `files` type now using its own coloured folder glyph as the guide glyph) nothing needed adapting. + +**🧰 CI & internals** + +- Baseline moved to **DSH 0.1.5-rc.1+** (#613): peer floor, devDependency pins, the CI mount lane, and `dsh.plugin.json`'s `engines.dsh` all follow (rc.1 is both the `latest` and `next` dist-tag on npm). +- Explicitly unchanged, no need to re-check: the global main-panel model (the `main` slot / `sidebar.panellist` / `ctx.layout` / root-scoped `rightbar` + `rightbar.session`, still not adopted), the file address grammar (`packages/util/workspace-path` only had its version bumped), the native tab-body host contract (`.paneBody` is still a block scroller with a definite height), the core / agent / session / subagent host APIs, and the `ui-primitives` export surface (only CodeBlock rendering changed). +- `@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1` still declares no `dependencies` while its bundle still bare-imports `anser` / `shiki` / `@shikijs/langs/*` / `mdast-util-*` / `micromark-*` / `katex` — the third-party devDependencies hoisted in the previous release therefore stay, and must not be reverted. +- Real-host verification (DSH 0.1.5-rc.1 + plugin 0.19.0): `typecheck` / `lint` / `check:consumer-types` green; unit suite **124 files · 1296 passed · 9 skipped**; `pnpm peers check` clean; the mount smoke lane against real rc.1 **7 passed** (including the tab-body fill assertion and the new "descriptions render only at ≤4 guide entries" assertion); on the local 3080 instance the guide still shows six icon+title capsules (6 > 4, so upstream renders no descriptions — upstream also raised the capsule `min-height` from 48px to 56px), clicking `AGENTS.md` in the file tree lands in the plugin editor (CodeMirror mounted), the side-chat composer sits on the pane floor (host box 962px == pane body 962px, composer 8px off the bottom), the bottom workbench's edges coincide with the centre column, and `pageerror` is 0 (the only console noise is the third-party `dsh-tauri-worktree` `/api/dsh-worktree/attach` 500, unrelated to this change). + +### v0.19.0-alpha.1 + +> 🧪 **alpha channel** (npm dist-tag `alpha`, install `dsh-better-sidebar@alpha`): supports **DSH 0.1.5-alpha.2+** only (peer floor `^0.1.5-alpha.2`, CI pins `@deepseek-ai/dsh@0.1.5-alpha.2`). Stay on **v0.19.0-alpha.0** for 0.1.5-alpha.1; the 0.1.2-rc.1 stable line keeps using **v0.18.1** (npm `latest`). + +**✨ New** + +- 🪟 **Every tab body now fills its pane** (#609): the native sidebar's tab-body host is a block scroller with a definite height, not a flex container, and the plugin's tab roots declared only `flex: 1` — so they collapsed to content height and the side-chat composer sat right after the transcript instead of at the pane bottom (a long transcript pushed it out of view). The native adapter now wraps every tab body in a `height: 100%` column flex host, restoring the same fill semantics the bottom workbench has for all plugin tabs, third-party `registerTab` descriptors included. + +**🐛 Fixes** + +- 🧭 **File address grammar follows DSH 0.1.5-alpha.2**: `fileAddressFor` always produces a session-scoped address and absolute paths keep their leading `/`; `parseFileAddress` is prefix-based and ignores `?`/`#` suffixes. +- 🪟 **Center-column anchor follows alpha.2's global panels**: the `conversation` slot became `main.conversation` under the root-scoped keyed `main` slot, so the locator now resolves the new key and skips `display: contents` slot hosts (alpha.1's old key still works). + +**🧰 CI & internals** + +- Baseline moved to DSH 0.1.5-alpha.2 (#609): peer floor, 22 devDependency pins, the CI mount lane, and `dsh.plugin.json` engines all follow; `pnpm peers check` is clean (hoisted the `dsh-session-persistence` transitive peer per §3-9). +- **`TabDescriptor.description` removed**: alpha.2's native guide entries no longer render a second line (they are icon + title capsules), so the field and the six `guideDesc*` keys (20 dictionaries) are gone; a new pane's default page is now selected from the registry (exactly one guide entry opens that page directly). + +### v0.19.0-alpha.0 + +> 🧪 **alpha channel** (npm dist-tag `alpha`, install `dsh-better-sidebar@alpha`): supports **DSH 0.1.5-alpha.1+** only (peer floor `^0.1.5-alpha.1`, CI pins `@deepseek-ai/dsh@0.1.5-alpha.1`). The 0.1.2-rc.1 stable line keeps using **v0.18.1** (npm `latest`). + +**✨ New** + +- 🖥️ **DSH's native right sidebar** (#604): the right column is now DSH's own sidebar — all seven plugin tab types register as native tab types with native tab bodies; every file open from the chat goes through `ctx.sidebarRight.openResource(dsh-resource://file/…)`; the `editor` type claims file resources at `extension` priority (outranking the built-in text preview) and takes over the built-in Files page kind (restored on unregister); cross-session opens queue until the target session is on screen. +- 🧩 **Own right panel and free windows retired** (#605): with the right column handed back to DSH, the plugin keeps only its bottom workbench (single split tree, per-session persistence) and registers its toggle into DSH's session-header utilities slot; the float API (`floats` / `floatTab` / `dockFloat` / `raiseFloat` / the "Move to Free Window" menu entry), the `'floatWindows'` feature string, and the `openByDefault` / `defaultWidthPercent` / `changesDiffFloat` settings are gone (a persisted `floats` field is ignored, so old documents still load). +- 🔗 **DSH 0.1.5 host contracts** (#603): the `assistant/chunk` event is gone — live deltas now fold from `agent/assistant-stream` frames (the side chat transcript's `live` field); `sessionPersistence.inspect` is gone — cold reads go through `open(id,'read')`; session headers use `SESSION_FORMAT_VERSION`. + +**🐛 Fixes** + +- Eight session-event read sites follow the 0.1.5 contracts (side-chat transcript, `jobs.output` replay, fork inheritance); custom seeds now carry the fork marker pair, so they no longer inherit the parent's unclaimed inbox input. + +**🧰 CI & internals** + +- The real-host mount smoke lane pins 0.1.5-alpha.1 and sweeps the native sidebar's guide page to open every plugin tab type; typecheck / lint / unit tests / mount lane all green. ### v0.18.1 @@ -323,9 +380,9 @@ All changes since v0.17.1 (the two intermediate version numbers v0.18.1-alpha.0 - 10+ new curated plugins: dsh-better-sidebar-icons (#441), dsh-sidenote (#451, formerly dsh-sidechat #470), dsh-github-workbench (#410), dsh-bilingual-reader (#379), dsh-server-deck (#413), dsh-md-export (#405), dsh-code-nav (#404), dsh-suhuang-scroll (#392), dsh-better-overleaf (#370), and more (each with 18+ language i18n coverage) -### v0.19.0-alpha.0 +### v0.19.0-alpha.0 (0.1.2-alpha.5 adaptation, unpublished, folded into v0.18.0) -> 🧪 **Alpha track**: this release targets **DSH 0.1.2-alpha.x only** (peer floor `^0.1.2-alpha.5`, npm dist-tag `alpha`, install `dsh-better-sidebar@alpha`). This version number was never published separately; its content is folded into the **v0.18.0** stable release. +> 🧪 **Alpha track**: this release targeted **DSH 0.1.2-alpha.x only** (peer floor `^0.1.2-alpha.5`, npm dist-tag `alpha`). This number was never published separately — its content shipped in the **v0.18.0** stable release, and the same number was later reused for the 0.1.5 adaptation line (see above). - 🔗 **Adapted to DSH 0.1.2-alpha.5 (published to npm, `alpha` dist-tag)**: the CI mount gate's pin, the `dsh.plugin.json` engines floor, and the `@deepseek-ai/*` peer / devDependencies baseline moved up to 0.1.2-alpha.5 (verified by a real-host mount smoke 14/14). `dsh-client-locale` has no alpha.5 upstream (newest is 0.1.2-alpha.3), so its peer floor / devDep pin trail the baseline and already accept the alpha.5 runtime (`pnpm peers check` reports zero mismatches — no new transitive peers to hoist). The code adapts to alpha.4's compatibility-flagged change — the `Session.events` property was removed, migrated to the on-demand `snapshotEvents()` API (8 sites: sidechat transcript live reads, fork inheritance, `jobs.output` replay, subagent activity), and the `seedLength` meta field the host dropped was removed from thread creation; alpha.4's remaining changes (bidirectional `send_message`, custom-model discovery reusing Profile headers, `SessionSeq`/`SessionLogOffset` strong typing) and alpha.5's upgrade-startup fix were verified not to touch any other plugin surface. @@ -527,7 +584,7 @@ All changes since v0.14.0: Since v0.4.0 the plugin exposes the `ctx.betterSidebar` service — other plugins can register sidebar pages and file viewers (the 8 built-in tabs + 6 viewers register through the same service). v0.12.1 completed the base capabilities (complete type exports, capability detection, state subscription, tab badges, lifecycle callbacks, targeted open, plugin-owned settings, etc.). -Full integration docs (complete fields, matching algorithm, HMR pitfalls, declarative settings, version detection, float windows and the skinning contract): **[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**; repository rules (hard constraints / CI / release) live in [`AGENTS.md`](./AGENTS.md). +Full integration docs (complete fields, matching algorithm, HMR pitfalls, declarative settings, version detection, the native-sidebar surface and the skinning contract): **[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**; repository rules (hard constraints / CI / release) live in [`AGENTS.md`](./AGENTS.md). ### ➕ Add Plugins (recommended plugin catalog) diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index 0dac147..9e11cd7 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -2,12 +2,38 @@ > 面向 **消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。 > -> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;badge/生命周期/定向打开/插件设置/版本探测 **v0.12.0+**;select 设置行(`settingSelect`)与外链认领(`urlTarget`)**v0.13.0+**;统一 `@deepseek-ai/cordis` 类型基底 **v0.15.2+**;自由窗口(`floatWindows`)**v0.16.0+**;终端固定(pin)**v0.17.0+**。当前版本 **v0.18.1**(正式版,仅支持 DSH 0.1.2-rc.1+;旧宿主 stable 线为 v0.17.1)。 +> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;badge/生命周期/定向打开/插件设置/版本探测 **v0.12.0+**;select 设置行(`settingSelect`)与外链认领(`urlTarget`)**v0.13.0+**;统一 `@deepseek-ai/cordis` 类型基底 **v0.15.2+**;终端固定(pin)**v0.17.0+**。当前版本 **v0.19.0**(正式版,npm `latest`,仅支持 DSH **0.1.5-rc.1+**;**0.1.5-alpha.2 及更早不再支持**——alpha.2 用户请用 v0.19.0-alpha.1,那是它的最后一版;0.1.2-rc.1 稳定线请用 v0.18.x)。**v0.19.0 移除了自绘右侧面板与自由窗口**(见 §0、§11)。 > 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 8 tab + 6 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。 > 仓库开发规则(硬约束 / CI / 发版)见 [AGENTS.md](../AGENTS.md)。 --- +## 0. 承载面:DSH 原生右侧栏 + 插件底部工作台(v0.19.0-alpha.0 起) + +从 v0.19.0-alpha.0 起,**右列完全属于 DSH**:你的 tab 渲染在 **DSH 自己的右侧栏**里(`ctx.sidebarRight` / `ctx.sidebarRightTabs`),插件把每个 `TabDescriptor` 注册成原生 tab 类型(`kind = descriptor.id`)+ 一个原生 tab 体。插件自己只保留**底部工作台**(分栏树、终端、会话内持久化)。对你的接入代码**没有影响**——仍然只调用 `ctx.betterSidebar`: + +- `registerTab` / `registerFileViewer` 签名不变; +- `openTab` / `openFile` 默认落到原生右侧栏;新增可选 `OpenTabSeed.target`(`'right'` 默认 / `'bottom'` 落插件的底部工作台); +- `updateTab` / `closeTab` / `activateTab` 认识原生 tab id(插件为每个原生 tab 维护一条合成 `SidebarTab` 记录,`tab.meta` / `tab.path` 的写入照旧生效)。 + +行为差异(写在这里以免踩坑): + +| 事项 | 说明 | +|---|---| +| 生命周期回调 | 原生面只有「一次打开」,不区分新建/聚焦,因此只触发 `onOpen`(`onActivate` 仅在插件自己的底部工作台里触发) | +| 去重 | 原生按 `(kind, 地址)` 去重:有 `createTab` 的类型每次新开一个 tab(terminal / browser / sidechat / diff),其余聚焦已有 tab;`dedupeKey` 的自定义语义不参与原生面 | +| 布局持久化 | 原生栏的布局**只在内存**(刷新后回到折叠默认),插件自己的底部工作台仍然持久化 | +| 跨会话打开 | 目标会话的右侧栏 store 未挂载时,打开会排队到该会话上屏后重放 | +| 内置类型接管 | 插件的 `editor` 类型以 `extension` 优先级认领 `dsh-resource://file/**`(压过内置 `ui-sidebar-documentpreview` 的 `text` 预览——即 `fallback` 带),并接管内置 `files` 页面 kind(`openTab('files')` 打开插件的文件树);插件卸载/禁用时内置实现自动复位 | +| 终端上限 | 终端 tab 的数量上限只统计插件自己底部工作台里的终端;原生栏里的终端不计入 | +| 底部工作台的开合 | 落到底部工作台的打开一律展开它(新建与聚焦都算),因此 `openTab` 的落点永远可见;开合按钮注册在 DSH 会话头的 utilities 槽(`conversation.session.header.utilities`),不在插件自己的宿主里 | +| 新建标签页列表 | 每个 tab 类型在原生 guide 里占一行:标题取 `title` + 图标取 `icon`(缺图标时宿主补一个方块占位),说明取可选的 `description`——**宿主只在 guide 列出的条目 ≤ 4 条时渲染说明**(上游 `MAX_DESCRIBED_ENTRIES = 4`),更长的列表整列丢掉所有说明;未声明 `description` 的条目渲染成单行「图标 + 标题」(rc.1 起 `description` 回到宿主契约,但**宿主与插件都没有兜底句**,所以插件恢复字段而不恢复旧的通用句);`hidden: true` 的类型不占行。插件的 `editor` 类型不再单独占行(它认领的文件资源由 `files` 接管页承载同一视图)。**注意默认组合看不到说明**:插件贡献 6 个 guide 条目(文件 / 文件变动 / 任务管理 / 侧边对话 / 终端 / 浏览器),已超过 4 条上限——要让说明出现,需在插件设置页关掉足够多的 tab 类型把 guide 压到 ≤ 4 条 | +| 新建面板的种子(alpha.2) | 在新会话打开原生新面板时,宿主从已注册的 guide 条目里播种:恰好 1 个条目 → 直接打开那一页;0 或 ≥2 个条目 → 打开指南。`revealIfOpened` 打开的「页面」在**同一 pane 内**强制去重(已在该 pane 就不再新建);由已有 tab 地址驱动的打开不受该去重影响 | +| alpha.2 全局面板(不接入) | 插件**不采用** alpha.2 引入的全局主面板模型——根级 keyed `main` 槽(预留 key `conversation`,由 ui-conversation 注册为 `main.conversation`)、根级 `sidebar.panellist` 列表槽(`SidebarPanelMetadata` / `SidebarPanelIconOwnerProps`)、`ctx.layout.selectPanel(MainPanelId|null)` / `beginNavigation()` / `dispose()`、全局标准 prop `usePanelInfo`,以及改根级并新增会话级 `rightbar.session` 子槽的 `rightbar`——这些只作兼容保留,不向其迁移 | +| 已移除 | 插件自绘右侧面板(含宽度拖拽 / 新会话默认宽度)与**自由窗口**(`features` 里的 `'floatWindows'` 已删除,v0.18.x 及更早版本的消费者请勿再 gate 该能力);`openByDefault` / `defaultWidthPercent` / `changesDiffFloat` 三个设置项同步删除(旧文档里的键会被忽略) | + +--- + ## 1. 总览:你能扩展什么 better-sidebar 从 v0.4.0 起把自己改造成一个**注册表服务**: @@ -155,7 +181,15 @@ interface TabDescriptor { id: string /** 标题(i18n 友好:传字符串或返回字符串的函数) */ title: string | (() => string) - /** 图标:ReactNode 或 (size: number) => ReactNode */ + /** + * 一行说明,渲染在标题下方(DSH 原生右侧栏的新建标签页 / guide 列表)。 + * **宿主只在 guide 列出的条目 ≤ 4 条时渲染它**(上游 `MAX_DESCRIBED_ENTRIES = 4`), + * 更长的列表整列丢掉所有说明——也就是说这是一行「锦上添花」,别把关键信息只放这里。 + * 不声明就不发 `description` 字段:宿主没有兜底句,插件也不补(通用句在所有页面上 + * 长得一样,纯噪音),条目就渲染成单行「图标 + 标题」。函数形式在渲染时求值,跟随语言。 + */ + description?: string | (() => string) + /** 图标:ReactNode 或 (size: number) => ReactNode(不声明时宿主补一个方块占位) */ icon?: ReactNode | ((size: number) => ReactNode) /** + 菜单排序(升序);默认 100。内置:editor=10, git=20, subagent=30, sidechat=35, terminal=40, browser=50 */ order?: number @@ -362,7 +396,7 @@ ctx.effect(() => { | id | order | single | hidden | 用途 | |---|---|---|---|---| | `editor` | 10 | 否(按 path 去重) | 否 | 唯一「文件窗口」(编辑/预览 + 资源管理)。chrome 恒合并形态:路径输入框 + 编辑器控件 + 可开关内嵌文件树(全局搜索 `fs.search`;状态存 `tab.meta.treeOpen/treeWidth`)。`editorExplorer`:关(默认)= 按 path 新开,无路径窗口 = 纯资源管理器;开 = 树点击/Enter 经 `updateTab` 原地切换(id/meta 不变),无路径窗口 = 带 chrome 空窗口。树右键「在新 Tab 中打开」「在侧边打开」(pane 右侧 split)。新会话 seed 空文件窗口(`title:'Files'`);旧 `explorer` tab 经 `sanitizeState` 迁移 | -| `git` | 20 | 是 | 是(本轮文件操作数) | 「文件变动」统一 tab(id 保留 `git` 以兼容持久化布局):**Git 视角**(原 Git 面板:staged/unstaged / 提交 / 历史 / worktree·子仓库选择)+ **本轮文件视角**(原 file-trace:模型读/写/编辑实时折叠,按文件分组、类型筛选);会话事件经插件自有宿主路由 `changes.ops` 供给(live 日志优先、冷会话回放持久化记录,`afterSeq` 增量),badge 读 tab 轮询写入的同步缓存。两视角共用底部可拖拽预览面板(`tab.meta.lens/previewH` 持久化),diff 渲染统一走 `src/client/diff/`(`DiffRows`/`DiffFiles`:mod 配对 + 行内高亮 + 语法着色 + 上下文折叠);Git 目标可展开为独立 diff tab——落点由二级设置 `changesDiffFloat` 决定(默认**自由浮窗**居中弹出,可选面板下半 split),设置项见 tab descriptor 的 `settings.toggles` select | +| `git` | 20 | 是 | 是(本轮文件操作数) | 「文件变动」统一 tab(id 保留 `git` 以兼容持久化布局):**Git 视角**(原 Git 面板:staged/unstaged / 提交 / 历史 / worktree·子仓库选择)+ **本轮文件视角**(原 file-trace:模型读/写/编辑实时折叠,按文件分组、类型筛选);会话事件经插件自有宿主路由 `changes.ops` 供给(live 日志优先、冷会话回放持久化记录,`afterSeq` 增量),badge 读 tab 轮询写入的同步缓存。两视角共用底部可拖拽预览面板(`tab.meta.lens/previewH` 持久化),diff 渲染统一走 `src/client/diff/`(`DiffRows`/`DiffFiles`:mod 配对 + 行内高亮 + 语法着色 + 上下文折叠);Git 目标可展开为独立 diff tab(落进工作台的 diff 分栏) | | `subagent` | 30 | 是 | 否 | 子代理拓扑 | | `sidechat` | 35 | 否(`sidechat:`,按 `meta.threadId` 去重) | 否 | 侧边对话(每对话一 Tab):打开即建空线程(首条消息赢得标签并同步标题);线程 = 插件自建子会话(种子继承父会话上下文,进行中回合以 `interrupted` 闭合;种子带合法 `subagent/descriptor`,SubagentView 按 `Side: ` 前缀过滤),`origin:'subagent'` 隐藏于主列表;走 `/sidebar/api/sidechat.*` 路由;头部菜单切换/重开(`parkSidechatReopen` + 确定性 id),关 Tab 释放 live agent;重开经 `collectOwnEvents` 回源到种子边界;「保存为新会话」= `session.fork`(`this` 敏感)。[设计文档](plans/2026-08-20-sidechat-tab-design.md) | | `terminal` | 40 | 否(`terminal:`) | 否 | 终端。v0.17.0+ 右键「固定到工作区/全局」:跨会话不消失,TabBar 内联虚拟 Tab(`pinned::`),就地按 home scope 连 PTY;global 全会话可见、workspace 仅同 cwd;`tab.pin = { scope, homeCwd? }` 随会话持久化,渲染期解析(`collectPinnedTabs` → `createPinnedVirtualTab` → `injectPinnedIntoTree`) | @@ -584,10 +618,10 @@ interface BetterSidebarService { // ── v0.12.0+ ────────────────────────────────────────────────────────── /** 插件版本(如 '0.17.1';与 package.json 同步,测试守护) */ readonly version: string - /** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' | - * 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' | - * 'pluginSettings' | 'urlTarget' | 'settingSelect' | 'floatWindows' - * ——用 `features.includes('xxx')` 按能力 gate。 */ + /** 能力清单(只增不删,唯一例外:v0.19.0 删除了 'floatWindows'): + * 'badge' | 'tabLifecycle' | 'updateTab' | 'openFile' | 'targetedOpen' | + * 'stateSubscription' | 'tabMeta' | 'pluginSettings' | 'urlTarget' | + * 'settingSelect' ——用 `features.includes('xxx')` 按能力 gate。 */ readonly features: readonly string[] /** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。 * session 未激活时 state/sessionId 为 undefined。 */ @@ -730,20 +764,18 @@ ctx.effect(() => | **i18n 跟随** | 文案跟随 DSH `ctx.locale`(词典在 `betterSidebar` 命名空间;Host-backed `locale.preference` 优先于浏览器语言并实时切换;缺失回退浏览器)。消费插件**不要**依赖内部 `t()`——标题传字符串或 `() => string` | | **第三语言覆盖(ja 等)** | 可选 peer `@huanlin/dsh-plugin-better-locale`(optional)提供 ja/ko 覆盖,**借用 DSH 英文槽位**(仅 DSH=en 时生效,zh 下惰性)。经 `ctx.get('betterLocale')` 注入 `t()`;未安装整段 no-op | | **懒加载 chunk** | 重依赖(xterm/CodeMirror)在独立 bundle(`lib/client-.js`),经 `/sidebar/bundle` 按需下发;factory 赋到 `globalThis.__dshChunks__[]`,由 `src/client/chunk-loader.ts` 物化,**不经** `__ModuleLoader__`。对消费插件透明 | -| **聊天文件打开漏斗(alpha 宿主)** | 聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)汇入 `ctx.remote.session.openWorkspacePath`(Typert remote 命名空间,cordis 服务 key `remote.session`,方法为 **accessor 属性**、异步挂载)。better-sidebar 的「聊天区文件在侧边栏打开」即在 `ctx.inject(['remote.session'], …)` 内以 defineProperty 遮蔽该方法(`src/client/openpath-intercept.ts`)。你的插件若要观测/旁路聊天文件打开,走同一服务;不要假设 pre-alpha 的 `ctx.workspaces.openPath` 存在(alpha 的 `IWorkspaces` 已无此方法) | +| **聊天文件打开漏斗(0.1.5 宿主)** | 聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)统一走 DSH 原生右侧栏的 `ctx.sidebarRight.openResource(fileAddressFor(sessionId, cwd, path))`(`packages/client/ui-chat/src/client/apply.ts` 是唯一调用点);`remote.session.openWorkspacePath` 在 0.1.5 客户端**已无调用者**,better-sidebar 的 openpath 拦截随之删除。你的插件若要观测/旁路聊天文件打开,注册原生 tab 类型认领 `dsh-resource://file/**`(见 [AGENTS.md §3](AGENTS.md) 第 10 条);不要假设 `ctx.workspaces.openPath`(已删除)或旧 `remote.session` 漏斗存在 | +| **原生 tab 体的高度契约** | 你的 tab 组件被挂在**全高列 flex 宿主**里:native 适配层给每个 tab 体(含 orphaned 回退)包一层 `height:100%` 的 `sidebar.module.css` `.nativeTabHost`(带 `data-dsh-native-tab-host`)——根用 `flex: 1` / `height: 100%` 并加 `min-height: 0` 填满面板。宿主的面板体 `.paneBody`(`dsh-client-ui-dockkit`)是**有确定高度的块级滚动容器,不是 flex 容器**,因此只靠外层 flex 容器支撑的根会塌成内容高度(sidechat 的输入框就贴不到面板底);DSH 自己的 tab 体(`ui-sidebar-documentpreview` 的文档预览、`ui-sidebar-files` 的 FilesBody)正是因此在根上声明 `height: 100%` | --- -## 11. 自由窗口(v0.16.0,`features` 含 `'floatWindows'`) +## 11. 自由窗口(v0.16.0 – v0.18.x,**v0.19.0 已移除**) -任意 tab(含你注册的)可拖出侧边栏成为悬浮**自由窗口**——对你的组件基本透明,只需知道以下语义: +自由窗口(tab 拖出侧边栏成为可移动/缩放的悬浮窗,`SidebarState.floats`、`floatTab`/`dockFloat`/`raiseFloat`、`features` 里的 `'floatWindows'`、右键「移动到自由窗口」、`src/client/FreeWindow.tsx`)在 **v0.19.0-alpha.0 整体删除**:右列交给 DSH 原生右侧栏后,插件只剩底部工作台,浮窗既没有语义归属也没有可用手势(拖出目标区域现在是原生栏的属地)。 -- **拖出**:tab 拖到主会话区域(conversation 列)松开即浮动(`floatTab`:移入 `SidebarState.floats`,默认 390×780 按视口钳制居中,清空 pane 折叠)。检测在 `Sidebar.tsx`(`body[data-dsh-tab-dragging]` 门控);窄视口禁用。右键「移动到自由窗口」始终可用。 -- **窗口操作**(`src/client/FreeWindow.tsx`,`[data-dsh-panel-host]` 内、z-42):头部拖动移动;拖到 pane(`[data-dsh-pane]`)松开**停靠**(`dockFloat`);右下角缩放(最小 320×200);点击置顶(层叠 = `floats` 数组序);X = `closeTab`(触发 `onClose`、释放终端)。 -- **持久化**:`floats` 随会话进 localStorage(`dsh-sidebar:v1:`);`sanitizeState` 宽容校验(非法条目单独丢弃、几何钳入视口、diff/ephemeral 不持久化)。 -- **服务语义**:`openTab` 聚焦命中浮动 tab = 置顶窗口(不重复开);`closeTab`/`activateTab` 关窗/置顶并触发回调;agent 终端 reconcile 移除已消失的浮动窗口。**浮窗内你的组件 `visible` 恒 true**。 -- **稳定寻址面**:`[data-dsh-float-window]` / `[data-dsh-float-id]` / `[data-dsh-pane]` / `[data-dsh-float-dock-over]` / `[class*='floatDropHint']`;全令牌驱动,头部 `-webkit-app-region: no-drag`。 -- **⚠️ portal 事件劫持陷阱**:拖拽表面子树含 portal 覆盖层时(如你的组件在 tab 头部区域渲染弹层),portal 后代合成事件**沿 React 树冒泡**回 `onPointerDown`,会被误判为拖拽开始(吞点击 + 抢 pointer,菜单/X 失灵;jsdom 不走此路径,只有 e2e 能抓)。拖拽起点必须带**同源守卫**:`event.currentTarget.contains(event.target)` 为假或目标在 `button` 内时直接返回(回归:`tests/free-window.spec.tsx` portaled-menu 用例)。 +- 升级到 v0.19.x 后请**不要**再 gate `features.includes('floatWindows')`,也不要引用 `floatTab` 等符号(类型声明里已不存在); +- 旧文档里持久化的 `floats` 字段会被 `sanitizeState` 直接忽略,会话不会因此加载失败; +- 需要「同一个 tab 在别处看」的场景,改用原生栏打开(`openTab` 默认落点)或底部工作台分栏(`OpenTabSeed.target: 'bottom'`)。 --- @@ -756,7 +788,7 @@ ctx.effect(() => - **面板表面**:右/底面板背景 = `var(--dsw-alias-bg-layer-1)`。**绝不消费 `--dsw-specific-sidebar-fill`**(宿主左导航专属,皮肤按左导航语义覆盖它,面板消费会失去填充)。换面板表面 = 覆写 `--dsw-alias-bg-layer-1`。 - **终端/编辑器表面**:`effectiveTokenValue` 读 `--dsw-alias-bg-base`——`transparent` 与 alpha < 0.9 的半透明值回退不透明底色(文字不叠背景画,issue #90);≥ 0.9 放行。 - **根锚点**:宿主 div 带 `data-dsh-better-sidebar`(append 到 body);其内**面板宿主层** `[data-dsh-panel-host]`(`fixed; inset:0; z-25; pointer-events:none; overflow:hidden+clip`,v0.13.1+),面板/开关簇 absolute 定位,免疫中间层 transform 劫持;页面级 transform 触发 `data-dsh-panel-host-degraded` 降级。`overflow` 级联是**契约**(`hidden` 兜底 + `clip` 收尾,`tests/panel-host-css.spec.ts` 守护):`hidden` 盒子仍是滚动容器,脚本滚动或浏览器 scroll-into-view 修正(焦点移入视口外区域、嵌套 iframe/工作台加载时抢焦点、面板滑出动画中 focus() 落点)会沿最近可滚祖先滚走整层——面板与开关簇集体偏离视口角(computed left/right 仍"正确",偏移藏在盒子自身 scroll offset 里);`clip` 裁剪语义相同但不产生滚动盒,任何路径都滚不动这层。皮肤作用域覆盖限定在 `[data-dsh-better-sidebar]` 内。 -- **布局变量**(`` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`。右面板宽度 = AppFrame 的 `padding-right` 预留(新版 `#root [data-dsh-frame]` / rc.8 `#root > [data-slot="root"] > div` 双锚点),AppFrame border box 保持完整桌面视口宽度(Harness 以此判定桌面/窄屏布局,避免插件面板展开误入窄屏);AppFrame 的 details 拖拽手柄按同一变量向左平移贴合列边缘。底部面板仍走 centerCol `margin-bottom`;centerCol 锚点 = **JS 标注**(禁止 `nth-child`):侧栏 shell 的定位器给测得的 centerCol 节点打 `[data-dsh-center-col]` 标签(`Sidebar.tsx` locate,节点更换/HMR 时随 ref 迁移),`layout.css` 用 `#root [data-dsh-center-col]` 选中(`drag-layout.e2e.ts` 断言恰一节点且为 `[data-slot="conversation"]` 的父级;frame 宽度与桌面 Session Log 由 `desktop-layout.e2e.ts` 断言)。 +- **布局变量**(`` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`。右面板宽度 = AppFrame 的 `padding-right` 预留(新版 `#root [data-dsh-frame]` / rc.8 `#root > [data-slot="root"] > div` 双锚点),AppFrame border box 保持完整桌面视口宽度(Harness 以此判定桌面/窄屏布局,避免插件面板展开误入窄屏);AppFrame 的 details 拖拽手柄按同一变量向左平移贴合列边缘。底部面板仍走 centerCol `margin-bottom`;centerCol 锚点 = **JS 标注**(禁止 `nth-child`):侧栏 shell 的定位器给测得的 centerCol 节点打 `[data-dsh-center-col]` 标签(`Sidebar.tsx` locate,节点更换/HMR 时随 ref 迁移),`layout.css` 用 `#root [data-dsh-center-col]` 选中(`drag-layout.e2e.ts` 断言恰一节点且为对话槽宿主的父级——alpha.2 起 shell 把 `#root [data-slot="main.conversation"]` 解析进列并跳过 `display: contents` 祖先(`center-column.ts` 的 `CENTER_COLUMN_SELECTOR` 同时认 `main.conversation` 与 alpha.1 的 `conversation`),定位器从槽宿主向上取第一个非 `contents` 的祖先;frame 宽度与桌面 Session Log 由 `desktop-layout.e2e.ts` 断言)。 - **桌面信号与标题栏**(v0.14.1+ 四方案模型 `SidebarPrefs.titleBarScheme`,唯一决策点 `src/client/titlebar-strip.ts` 纯函数): - 壳信号(只读,不自动触发修改):URL `dsh-desktop-mode` / `dsh-desktop-platform` / 可选 `dsh-desktop-titlebar-inset`(0–120 clamp)。 - **strip 取值链**:⓪ `web` 方案强制 0;① `navigator.windowControlsOverlay` 真实几何(`wco.ts` 订阅 `geometrychange`,**为 0 也权威**,`visible=false` 幽灵 API 视为缺失);② URL inset;③ 壳预设 `stripFor`(仅 `preset`);④ 手动 `titleBarStripPx`(仅 `custom`);⑤ 0。驱动 `body[data-dsh-title-bar-compat]` + `--dsh-title-bar-strip`。 @@ -861,8 +893,9 @@ better-sidebar 的内置 tab 和 viewer 就是参考实现("吃狗粮"), - **`src/client/builtins/`**:7 个内置 tab(tabs.tsx)+ 6 个内置 viewer(viewers.tsx)的注册代码 + 聚合与 disposer 生命周期(index.ts);Office 预览见 plugins-viewers.ts - **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现(含匹配算法、dedupe、createTab、启用态 gating) -- **`src/client/Sidebar.tsx`**:`TabContent` 分发(查 `getTab` → 调 descriptor.component;未注册 → ``)、`+` 菜单构建(order 排序 + available disabled + 禁用过滤)、自由窗口拖拽检测 -- **`src/client/FreeWindow.tsx`**:自由窗口(移动/停靠/缩放/置顶,§11) +- **`src/client/Sidebar.tsx`**:底部工作台外壳 + `TabContent` 分发(查 `getTab` → 调 descriptor.component;未注册 → ``)、`+` 菜单构建(order 排序 + available disabled + 禁用过滤) +- **`src/client/native/`**:原生右侧栏接入(tab 类型注册、合成 `SidebarTab` 适配、资源地址、跨会话打开排队) +- **`src/client/sidebar/bottom-toggle.tsx`**:底部工作台开合按钮(注册进 DSH 会话头 utilities 槽) - **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + 嵌套设置行 + 开关持久化) - **`src/client/api.ts`**:`/sidebar` API 的封装(复制其 fetch 模式到你的插件) - **`src/client/plugins-tabs.ts`** / **`plugins-viewers.ts`**:推荐插件目录(「添加插件」弹窗数据源;加一条数据即上架,`tests/plugin-list.spec.ts` 守护) diff --git a/docs/plans/2026-09-09-dsh-0.1.5-alpha.2-adaptation.md b/docs/plans/2026-09-09-dsh-0.1.5-alpha.2-adaptation.md new file mode 100644 index 0000000..b8416c7 --- /dev/null +++ b/docs/plans/2026-09-09-dsh-0.1.5-alpha.2-adaptation.md @@ -0,0 +1,88 @@ +# DSH 0.1.5-alpha.2 适配(v0.19.0-alpha.1 基线) + +日期:2026-09-09 分支:`feat/dsh-0.1.5-alpha.2` + +## 背景 + +DSH `dsh-v0.1.5-alpha.1..dsh-v0.1.5-alpha.2` 的宿主契约里有一批会实际波及本插件的变更。本计划记录哪些值得处理、哪些只做兼容、以及每一处的子系统级落点。插件对外的全部行为差异以 [docs/external-plugin-guide.md](../external-plugin-guide.md) 为准;仓库级适配要点(基线、CI 钉版、宿主契约)同步到 [AGENTS.md](../AGENTS.md) §3。 + +## 上游 delta(alpha.1 → alpha.2,影响本插件的六项) + +| # | 上游变更 | 对插件的影响 | +|---|---|---| +| 1 | `SidebarRightGuideEntry` 去掉 `description` 字段;指南条目变为「图标 + 标题」胶囊 | 删除 `TabDescriptor.description` 与 6 个 `guideDesc*` 词典键(连同 `nativeGuideDesc` 兜底句——不再有第二行说明) | +| 2 | 新增**全局面板**:根级 keyed `main` 槽(预留 key `conversation`,由 ui-conversation 注册为 `main.conversation`)、根级 `sidebar.panellist` 列表槽(`SidebarPanelMetadata` / `SidebarPanelIconOwnerProps`)、`ctx.layout.selectPanel(MainPanelId|null)` / `beginNavigation()` / `dispose()`、全局标准 prop `usePanelInfo`;`rightbar` 改根级并新增会话级 `rightbar.session` 子槽 | 插件**不接入**(兼容 only) | +| 3 | 原生 tab 体宿主 `.paneBody`(`dsh-client-ui-dockkit`)是**有确定高度的块级滚动容器**,不是 flex 容器;DSH 自己的 tab 体(`ui-sidebar-documentpreview` 的文档预览、`ui-sidebar-files` 的 FilesBody)在根上声明 `height: 100%` | 原生适配层给每个 tab 体包一层 `height: 100%` 的列 flex 宿主(`sidebar.module.css` `.nativeTabHost`,带 `data-dsh-native-tab-host`),修复 sidechat 输入框贴不到面板底的根塌陷 | +| 4 | 文件地址语法:`fileAddressFor` 一律返回 session 作用域地址(绝对路径保留前导 `/`);`parseFileAddress` 前缀解析并去 `?`/`#`;`absoluteFileAddress` 仍在 | 镜像 alpha.2 语法(插件自实现于 `src/client/resource-address.ts`——客户端 bundle 纯度门禁禁 value-import 未列名的 `@deepseek-ai/*`,不能直接引 DSH 工具包) | +| 5 | `ui-sidebar-textpreview` 被 `ui-sidebar-documentpreview` 取代(同 `text` kind、`fallback` 带、`dsh-resource://file/**` 模式) | 插件 `editor` 仍以 `extension` 优先级认领 `dsh-resource://file/**`,压过其 `text` 的 `fallback` 带 | +| 6 | 基线升到 0.1.5-alpha.2(peer 下限 / manifest / CI 钉版) | 插件基线 = alpha.2 only;alpha.1 用户留在 v0.19.0-alpha.0 | + +> 后续(2026-09-10):`SidebarRightGuideEntry.description` 在 DSH 0.1.5-rc.1 回归(可选,且宿主只在 guide 列出的条目 ≤ 4 条时渲染),插件已在 **v0.19.0** 恢复该字段——见 [2026-09-10-dsh-0.1.5-rc.1-adaptation.md](./2026-09-10-dsh-0.1.5-rc.1-adaptation.md)。 + +## 决策 + +- **基线 = alpha.2 only**:peer 下限 `^0.1.5-alpha.2`、manifest `>=0.1.5-alpha.2`、CI 钉 `@deepseek-ai/dsh@0.1.5-alpha.2`;不再对 alpha.1 做运行时兼容。 +- **全局面板:兼容 only,不接入**——不迁移到 `main` 槽 / `sidebar.panellist` / `ctx.layout` / `usePanelInfo` / 根级 `rightbar`,仅保证在 alpha.2 宿主上不破坏。 +- **删除 `TabDescriptor.description`**:宿主不再渲染第二行,字段留着只会在类型上撒谎;同步删 6 个 `guideDesc*` 与 `nativeGuideDesc` 兜底句。 +- **修复 tab 体填充**:native 适配层为每个 tab 体(含 orphaned 回退)包 `height:100%` 列 flex 宿主 `.nativeTabHost`;tab 组件根遵循 `flex:1` / `height:100%` + `min-height:0` 契约。 +- **镜像 alpha.2 地址语法**:`fileAddressFor` 不再产生 `absolute` 作用域(但仍解析旧地址与第三方地址),`parseFileAddress` 前缀解析并去 `?`/`#`。 +- **中心列锚点跟随 alpha.2**:`center-column.ts` 的 `CENTER_COLUMN_SELECTOR` 改为 `#root [data-slot="main.conversation"], #root [data-slot="conversation"]`,`resolveCenterColumn` 从对话槽宿主向上跳过 `display: contents` 祖先取真实列;alpha.1 的 `[data-slot="conversation"]` 依旧认可。 + +## 改动清单(子系统级) + +| 子系统 | 文件 | 变更 | +|---|---|---| +| 基线 | `package.json` | 版本 → `0.19.0-alpha.1`;dsh-* peer 全部 `^0.1.5-alpha.2`;devDeps 同步钉 `0.1.5-alpha.2` | +| 基线 | `pnpm-lock.yaml` | 锁文件对齐 alpha.2 | +| 基线 | `dsh.plugin.json` | `version` → `0.19.0-alpha.1`;`engines.dsh` → `>=0.1.5-alpha.2` | +| 基线 | `.github/workflows/ci.yml` | mount lane 钉 `@deepseek-ai/dsh@0.1.5-alpha.2` | +| 基线 | `package.json` | 按 §3-9 补提 `dsh-session-persistence`(`pnpm peers check` 唯一新失配项) | +| 基线 | `package.json` | **上游打包缺陷**:`@deepseek-ai/dsh-client-ui-primitives@0.1.5-alpha.2` 的 manifest 不再声明任何 `dependencies`(alpha.1 声明了 19 个),但其 `lib/*.js` 仍裸 import `anser` / `shiki` / `@shikijs/langs/*` / `mdast-util-*` / `micromark-*` / `katex`。宿主自身由预构建前端 bundle 满足,独立安装的消费方(本仓库的 dev/test 树)必须自备,故把 alpha.1 声明的同一组版本提升进 devDependencies(`@shikijs/langs`、`@types/mdast`、`anser`、`katex`、`mdast-util-from-markdown/gfm/math`、`micromark-core-commonmark/extension-gfm/extension-math/factory-space/util-character/util-classify-character/util-sanitize-uri/util-symbol/util-types`、`shiki`;`clsx` 已在 dependencies) | +| 基线/文档 | `AGENTS.md` | §2/§3 基线戳、§10 补 alpha.2 行为差异(本 workstream 回填) | +| Tab 契约 | `src/client/service.ts` | `TabDescriptor` 删除 `description` 字段;`SIDEBAR_SERVICE_VERSION` → `0.19.0-alpha.1` | +| Tab 契约 | `src/client/builtins/tabs.tsx` | 6 个内置 tab 删除 `description` 行 | +| Tab 契约 | `src/client/native/index.ts` | native 注册的 guide 条目去掉 `description`(删 `descriptionOf` 与通用兜底);`guide` 条目只剩 `order`/`title`/`icon` | +| 词典 | `src/client/locales.ts` + 各 `locales-*.ts` | 删除 `nativeGuideDesc` + 6 个 `guideDesc*` 词条(zh/en + 全部第三方语言) | +| Tab 高度 | `src/client/native/tab-adapter.tsx` | `NativeTabBody` 把 live 组件与 orphaned 回退都包进 `css.nativeTabHost`(带 `data-dsh-native-tab-host`) | +| Tab 高度 | `src/client/sidebar.module.css` | 新增 `.nativeTabHost`(`display:flex; flex-direction:column; height:100%; min-height:0`) | +| 地址语法 | `src/client/resource-address.ts` | 重写为镜像 alpha.2 文件地址语法(`fileAddressFor` 不再产 `absolute` 作用域、`parseFileAddress` 前缀去 `?`/`#`、保留 `absolute` 解析) | +| 中心列锚点 | `src/client/center-column.ts` | `CENTER_COLUMN_SELECTOR` 加 `main.conversation`;新增 `columnOfSlotHost` 跳过 `display: contents` 祖先 | +| 中心列锚点 | `src/client/sidebar/use-center-column.ts` | 宿主查找逻辑对齐 alpha.2 | +| 中心列锚点 | `src/client/layout.css` | 选择器/注释同步 `main.conversation` 与 `display: contents` 跳过 | +| 测试 | `tests/builtins.spec.ts` | 指南断言从 `description` 改成 `title`(图标+标题胶囊) | +| 测试 | `tests/native-surface.spec.ts` | 注册/guide 断言去 `description`;断言 guide 条目不再携带 `description` | +| 测试 | `tests/center-column-locator.spec.ts` | 覆盖 `main.conversation` 解析与 `display: contents` 跳过 | +| 测试 | `tests/resource-address.spec.ts` | 覆盖 alpha.2 地址语法 | +| 测试 | `tests/e2e/mount.e2e.ts` | guide 页断言跟随胶囊形态;新增 tab 体填充断言(可见 tab 的 `[data-dsh-native-tab-host]` 盒高 == 其带盒祖先高 ±2px,且 sidechat composer 底边距面板底 ≤12px) | +| 文档 | `docs/external-plugin-guide.md` | §0 行为差异表、§4.1 删 `description`、§10 加 tab 体高度契约、§12 中心列锚点、`ui-sidebar-documentpreview` | +| 文档 | `docs/plans/2026-09-09-dsh-0.1.5-alpha.2-adaptation.md` | 本计划 | + +## 已知差距 + +- **side-chat 线程仍只携带 `subagent/descriptor`,不携带 alpha.2 的 `subagent/catalog`**:DSH 自己的直接子代理发现现在优先读 `catalog`,而我们沿用的 `subagents.listDescendants`(`src/subagent-live-route.ts`)与转录种子的 `type: 'subagent/descriptor'`(`src/sidechat-routes.ts`)路径不变。catalog 只作为新增读面出现,不构成对现有路径的破坏,本期不跟进。 + +## 验证 + +- **门禁**(本分支 HEAD):`pnpm typecheck` / `pnpm lint` / `pnpm check:consumer-types` 全绿;`pnpm vitest run --exclude '**/*.e2e.ts'` = 124 files / 1295 passed / 9 skipped;`pnpm peers check` = No peer dependency issues found。 +- **真机挂载冒烟**(`DSH_CMD` 指向 npm 安装的 DSH 0.1.5-alpha.2,`scripts/e2e-mount.sh`):7 passed。新增的 tab 体填充断言(可见 tab 的 `[data-dsh-native-tab-host]` 盒高 == 其带盒祖先 ±2px,sidechat composer 底边距面板底 ≤12px)是这次修复的回归守护——它在修复前失败(`gap -862px`,因为断言最初漏跳过 `display: contents` 槽宿主)。 +- **真机 3080(web profile 升到 alpha.2 + 插件 0.19.0-alpha.1)**: + - 原生 guide 六行均为 48px「图标+标题」胶囊,无第二行:文件 / 文件变动 / 任务管理 / 侧边对话(beta) / 终端 / 浏览器。 + - 文件树点击 `AGENTS.md` → tab 标题 `AGENTS.md` + CodeMirror 挂载(`editor` 接管成立)。 + - **sidechat 输入框贴底**:原生右侧栏 `hostHeight 962 == paneBodyHeight 962`(fillGap 0),composer 底边距面板底 8px(即它自己的 margin);底部工作台同样 8px。 + - 底部工作台中心列锚点:`[data-dsh-bottom-panel]` 与 `[data-dsh-center-col]` 左右边完全重合(gap 0)——证明 `main.conversation` + 跳过 `display: contents` 的解析在真机成立;真机 `[data-slot]` 清单同时确认 `main` / `main.conversation` / `rightbar.session` 已存在、旧 `conversation` 槽已消失。 + - `pageerror` 0 条;控制台仅 `POST /api/dsh-worktree/attach` 500(第三方 dsh-tauri-worktree 插件,与本次改动无关)。 + - 未覆盖:**聊天里的文件链接点击**——当前可见会话没有可解析的文件提及(内联 `code` 是纯文本,不是链接),该漏斗(`ctx.sidebarRight.openResource(fileAddressFor(...))`)由挂载车道与 `tests/resource-address.spec.ts` 覆盖,本实例未实测。 +- 仓库没有守护指南文档章节的测试;`tests/builtins.spec.ts` / `tests/native-surface.spec.ts` 守护的是 guide 行形态而非文档文本。 +- 早期本分支相关测试曾整体收集失败:`@deepseek-ai/dsh-client-ui-primitives@0.1.5-alpha.2` 在模块加载时裸 `import 'anser'`,而该包 manifest 已不再声明依赖。补齐 devDependencies 后恢复(见改动清单)。 + +## 不做 + +- 不接入 alpha.2 全局面板(`main` 槽 / `sidebar.panellist` / `ctx.layout` / `usePanelInfo` / 根级 `rightbar`)。 +- 不引入 `subagent/catalog` 读取面。 +- 不改 DSH 源码(仓库硬约束 §1)。 + +## 落地 + +- #603 → #604 → #605 → #609 依序合入 `main`(各自 retarget 到 main 后以 merge commit 合并);main 的 `package.json` 为 `0.19.0-alpha.1`、`engines.dsh` 为 `>=0.1.5-alpha.2`。main 的 CI 首轮 `ci-windows` 因既有的 pty/git 5s 超时抖动失败,重跑后三 job 全绿。 +- 发布 [v0.19.0-alpha.1](https://github.com/omdsh-dev/DSH-better-sidebar/releases/tag/v0.19.0-alpha.1):`release.yml` 通过全门禁后经 npm Trusted Publishing 发布,npm `alpha` dist-tag → `0.19.0-alpha.1`(带 provenance)。发布产物核对:`lib/index.js` 与本地已验证构建逐字节一致;`lib/client.js` 仅差打包非确定性(已知);`package.json` 仅少 dev-only 字段(`packageManager` / `prepare` / `prepublishOnly`,npm 发布时剥离)。 +- 注意:新版本在 registry 上可见有约 5 分钟传播延迟(版本元数据先到、tarball 后到),期间 `npm view`/tarball 可能 404——不是发布失败。 diff --git a/docs/plans/2026-09-10-dsh-0.1.5-rc.1-adaptation.md b/docs/plans/2026-09-10-dsh-0.1.5-rc.1-adaptation.md new file mode 100644 index 0000000..8e5628a --- /dev/null +++ b/docs/plans/2026-09-10-dsh-0.1.5-rc.1-adaptation.md @@ -0,0 +1,86 @@ +# DSH 0.1.5-rc.1 适配(v0.19.0 正式版基线) + +日期:2026-09-10 分支:`feat/dsh-0.1.5-rc.1` + +## 背景 + +DSH `dsh-v0.1.5-alpha.2..dsh-v0.1.5-rc.1`(上游 commit `183f08e9c`,npm 发布 2026-09-10T03:12Z)的 delta 很小:373 个变更文件,绝大多数是各包 `package.json` 版本号、原生右侧栏预览 UI 打磨与测试快照。触及本插件的只有一处契约回归——`SidebarRightGuideEntry.description` 回来了。本计划记录该 delta、插件对它的取舍、以及 v0.19.0 正式版的基线决策。上一版(alpha.2)的适配记录见 [2026-09-09-dsh-0.1.5-alpha.2-adaptation.md](./2026-09-09-dsh-0.1.5-alpha.2-adaptation.md);插件对外的全部行为差异以 [docs/external-plugin-guide.md](../external-plugin-guide.md) 为准;仓库级适配要点(基线、CI 钉版、宿主契约)同步到 [AGENTS.md](../AGENTS.md) §2/§3。 + +## 上游 delta(alpha.2 → rc.1,影响本插件的三项) + +| # | 上游变更 | 对插件的影响 | +|---|---|---| +| 1 | `SidebarRightGuideEntry.description?: () => string` 回归(可选):原生指南**只在列出条目 ≤ 4 条时**渲染它(上游 `MAX_DESCRIBED_ENTRIES = 4`),更长的列表整列丢弃说明;没有 `icon` 的条目改由宿主补方块占位 | 恢复 `TabDescriptor.description`(六个内置类型声明它,`guideDesc*` 词条一并回来);**不恢复**旧的通用兜底句 `nativeGuideDesc`——宿主自己没有兜底,通用句在所有页面上长得一样、纯噪音;未声明该字段的 descriptor 就不发 `description` 字段 | +| 2 | 内置 `files` 类型重新声明指南说明,且改用彩色的 `FileTypeIcon kind="folder"` 作指南字形 | 仅背景信息:`files` 页面 kind 由插件接管,该行现在随插件渲染(`editor` descriptor 的图标与说明) | +| 3 | `@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1` **仍不声明 `dependencies`**,而其 bundle 继续裸 import `anser` / `shiki` / `@shikijs/langs/*` / `mdast-util-*` / `micromark-*` / `katex` | 上一版提升进 devDependencies 的那组第三方包**不得回退**(回退即 vitest 一加载 primitives 就 `Cannot find package 'anser'`);`ui-primitives` 的导出面未变,只有 CodeBlock 渲染变化 | + +**明确未变**(不必再核): + +- 全局主面板模型——根级 keyed `main` 槽、`sidebar.panellist`、`ctx.layout.selectPanel` / `beginNavigation`、根级 `rightbar` + `rightbar.session`;插件仍不接入。 +- 文件地址语法——`packages/util/workspace-path` 只动了 `package.json` 的版本号。 +- 原生 tab 体宿主契约——`dsh-client-ui-dockkit` 的 `.paneBody` 仍是「有确定高度的块级滚动容器」,只加了两行 z-index。 +- core / agent / session / subagent 宿主 API——仅版本号变化。 + +## 决策 + +- **插件版本 0.19.0(无 prerelease 后缀)**:`release.yml` 按「版本号是否含 `-`」自动选 dist-tag,因此本版发 npm **`latest`**;npm 的 `alpha` 标签保持指向 `0.19.0-alpha.1`(上游 rc.1 自己的 GitHub Release 仍标 pre-release,与本插件的通道无关)。 +- **基线 = DSH 0.1.5-rc.1+ only**:peer 下限 `^0.1.5-rc.1`、manifest `engines.dsh >=0.1.5-rc.1`、CI 钉 `@deepseek-ai/dsh@0.1.5-rc.1`。DSH **0.1.5-alpha.2 用户留在 v0.19.0-alpha.1**,DSH **0.1.2-rc.1 用户留在 v0.18.1**。 +- **恢复 `description`,不恢复通用兜底句**:宿主渲染第二行时读 descriptor 自己的说明;`nativeGuideDesc` 兜底句不再存在——没写说明的条目就是单行「图标 + 标题」。 +- **接受 ≤4 渲染规则,不做额外工程**:插件默认贡献 6 个 guide 条目 > 4,说明在默认组合下不渲染(见下节)。不为此裁剪内置类型、不改条目数、不把关键信息只放进说明里。 +- **升级本地 3080 实例**到 DSH 0.1.5-rc.1 + 插件 0.19.0,作为真机观察点。 + +## ≤4 渲染规则的后果(重要) + +上游只在 guide 列出的条目 **≤ 4 条**时渲染 `description`(`MAX_DESCRIBED_ENTRIES = 4`),更长的列表整列丢掉所有说明——注意是「整列丢弃」,不是截断到前四条。插件默认贡献 6 条 guide 条目(文件 / 文件变动 / 任务管理 / 侧边对话 / 终端 / 浏览器),**因此在默认组合下,插件与内置类型的说明都不会出现**,六条仍是「图标 + 标题」。 + +说明只在读者于插件设置页关掉足够多的 tab 类型、把 guide 压到 ≤ 4 条时才出现(逐类型开关;关掉 `editor` 会同时移除接管出去的「文件」行)。这是上游行为而非插件缺陷:插件恢复字段是为了让「关到 ≤ 4 条」的读者拿到真实说明,而不是为了在默认组合里显示什么。指南 §0 与 §4.1 都写明了这条规则,消费插件不应把关键信息只放在 `description` 里。 + +## 改动清单(子系统级) + +| 子系统 | 文件 | 变更 | +|---|---|---| +| 基线 | `package.json` | 版本 → `0.19.0`(无后缀);dsh-* peer 全部 `^0.1.5-rc.1`;devDeps 同步钉 `0.1.5-rc.1`;`dsh-client-locale` 跟随基线到 `0.1.5-rc.1`(该包已回到与 DSH 同 tuple 发版,§3-9 的落后例外保持消除) | +| 基线 | `pnpm-lock.yaml` | 锁文件对齐 rc.1 | +| 基线 | `dsh.plugin.json` | `version` → `0.19.0`;`engines.dsh` → `>=0.1.5-rc.1` | +| 基线 | `.github/workflows/ci.yml` | mount lane 钉 `@deepseek-ai/dsh@0.1.5-rc.1`(rc.1 在 npm 上同时是 `latest` 与 `next`) | +| 基线 | `package.json` | 上一版提升的第三方 devDependencies(anser / shiki / `@shikijs/langs` / `mdast-util-*` / `micromark-*` / katex)保留不回退——`ui-primitives@0.1.5-rc.1` 仍无 `dependencies` | +| Tab 契约 | `src/client/service.ts` | `TabDescriptor` 恢复可选 `description`;`SIDEBAR_SERVICE_VERSION` 跟随插件版本(`0.19.0`) | +| Tab 契约 | `src/client/builtins/tabs.tsx` | 六个内置类型(editor / git / subagent / sidechat / terminal / browser)重新声明 `description` | +| Tab 契约 | `src/client/native/index.ts` | guide 条目在 descriptor 声明了说明时带上 `description`;未声明就不发该字段(不补通用句) | +| 词典 | `src/client/locales.ts` + 19 份 `locales-*.ts` | 恢复 6 个 `guideDesc*` 词条(zh / en / ja 与其余第三方语言) | +| 测试 | `tests/builtins.spec.ts` | 指南断言从「仅 `title`」改回「`title` + `description`」 | +| 测试 | `tests/native-surface.spec.ts` | 断言 guide 条目携带 `description`;`editor` 类型仍不单独占行、接管行承载其图标与说明 | +| 文档 | `docs/external-plugin-guide.md` | 顶部基线行(v0.19.0 / DSH 0.1.5-rc.1+ / 两个钉版旧版本)、§0 「新建标签页列表」行、§4.1 恢复 `description` 字段与 ≤4 规则 | +| 文档 | `docs/plans/2026-09-09-dsh-0.1.5-alpha.2-adaptation.md` | 加一行前向链接(该版删掉的 `description` 在 rc.1 回归) | +| 文档 | `docs/plans/2026-09-10-dsh-0.1.5-rc.1-adaptation.md` | 本计划 | +| 文档 | `AGENTS.md` | §2 CI 基线戳、§3 标题与基线 prose、§3 第 9/10 条(devDeps 不回退 / `description` 回归) | +| 文档 | `README.md` / `README_EN.md` | 顶部与安装节的「支持的 DSH 版本」徽章、安装节正式版说明、新增 `### v0.19.0` 更新段落 | + +## 验证 + +- **本工作流(文档)未执行任何门禁或真机验证**,只做了与工作树现状的交叉核对:`package.json` 为 `0.19.0`(dsh-* peer 与 devDep 均 `0.1.5-rc.1`)、`dsh.plugin.json` 的 `engines.dsh` 为 `>=0.1.5-rc.1`、`.github/workflows/ci.yml` 钉 `@deepseek-ai/dsh@0.1.5-rc.1`、`SIDEBAR_SERVICE_VERSION` 为 `0.19.0`、`src/client/builtins/tabs.tsx` 六个类型与 `src/client/native/index.ts` 的 guide 条目(含 `files` 接管行)重新带上 `description`、`src/client/locales*.ts` 恢复 6 个 `guideDesc*` 键。 +- 仓库**没有守护指南文档章节的测试**——`tests/builtins.spec.ts` / `tests/native-surface.spec.ts` 守护的是 guide 行的形态,不是文档文本,因此文档改动没有对应的测试命令可跑。 +- **待补(由主代理在实施完成后回填,本文件不预填未经执行的结论)**:门禁数字(`pnpm typecheck` / `pnpm lint` / `pnpm vitest run --exclude '**/*.e2e.ts'` / `pnpm peers check`)、`scripts/e2e-mount.sh` 真机挂载车道的通过数,以及本地 3080 实例(DSH 0.1.5-rc.1 + 插件 0.19.0)上的实测结果——默认组合下 6 条 guide 行仍是「图标 + 标题」、关掉 tab 类型至 ≤ 4 条后说明出现、以及 `pageerror` / 控制台状况。 + +### 回填(主代理实测) + +- **门禁**:`pnpm typecheck` / `pnpm lint` / `pnpm check:consumer-types` 全绿;`pnpm vitest run --exclude '**/*.e2e.ts'` = **124 files / 1296 passed / 9 skipped**;`pnpm peers check` = No peer dependency issues found(rc.1 未新增失配的传递 peer,因此没有再提升任何 devDep)。 +- **挂载冒烟**(`DSH_CMD` 指向 npm 安装的 0.1.5-rc.1,`scripts/e2e-mount.sh`):**7 passed**,含 tab 体填充断言与新增的 ≤4 条描述断言。 +- **≤4 断言的真实形态**:默认 6 条下永远拿不到说明文本,所以该用例先用插件自己的 `settings.get` / `settings.update` 把启用集合压到 3 个类型(files / sidechat / browser),断言 `files` 胶囊文本含英文说明片段 `workspace tree`,并断言被禁用的三个条目离开 guide。 + - **踩坑(已修)**:恢复设置时最初只把原始 `tabsEnabled`(scratch profile 里是 `{}`)原样发回——而该路由的 patch 是**逐键合并**,空对象清不掉刚写下的三个 `false`,于是同车道的 `perf.e2e.ts` 扫 guide 时找不到 `git` 条目而失败。改为对本次动过的每个键显式恢复(原值缺省即 `true`)后全绿;这条经验对任何"临时改设置再恢复"的 e2e 都适用。 +- **真机 3080**(DSH 0.1.5-rc.1 + 插件 0.19.0):guide 六行胶囊均为「图标 + 标题」、**无说明**(6 > 4,符合上游规则);胶囊高度 56px(上游把 `min-height` 从 alpha.2 的 48px 调到 56px);文件树点击 `AGENTS.md` → tab 标题 `AGENTS.md` + CodeMirror 挂载;sidechat 宿主盒 962px == `.paneBody` 962px(fillGap 0),composer 底边距 8px;底部工作台与 `[data-dsh-center-col]` 左右边完全重合(gap 0);`pageerror` 0;控制台仅第三方 `dsh-tauri-worktree` 的 `/api/dsh-worktree/attach` 500(alpha.2 期同样存在,与本次无关)。rc.1 真机的 `[data-slot]` 清单与 alpha.2 完全一致(`main` / `main.conversation` / `rightbar` / `rightbar.session`),印证"全局面板模型未变"。 + +## 不做 + +- 不为 ≤4 渲染规则裁剪内置 tab 类型、调整条目数或改写图标。 +- 不恢复 `nativeGuideDesc` 通用兜底句。 +- 不接入 alpha.2 的全局面板(`main` 槽 / `sidebar.panellist` / `ctx.layout` / `usePanelInfo` / 根级 `rightbar`)——沿用上一版结论,rc.1 上这些面同样只是兼容保留。 +- 不回退提升进 devDependencies 的第三方包。 +- 不改 DSH 源码(仓库硬约束 §1)。 + +## 落地 + +- [#613](https://github.com/omdsh-dev/DSH-better-sidebar/pull/613) 合入 `main`(merge commit `754974a`);main 的 `package.json` 为 `0.19.0`、`engines.dsh` 为 `>=0.1.5-rc.1`。PR 的 `ci` / `ci-windows` / `plugin-mount` 三个 check 全绿。 +- 发布 [v0.19.0](https://github.com/omdsh-dev/DSH-better-sidebar/releases/tag/v0.19.0):`release.yml` 通过全门禁后经 npm Trusted Publishing 发布到 **`latest`**(带 provenance);实测 dist-tags:`latest = 0.19.0`、`alpha = 0.19.0-alpha.1`(不变)、`beta` 不变。 +- 产物核对:`lib/index.js` 与本地已验证构建**逐字节一致**,且与 `v0.19.0-alpha.1` 的同名文件同哈希(宿主侧代码在这两版之间确实没动);`lib/client.js` 仅差已知的打包非确定性;发布包的 client bundle 内含恢复后的说明文案。 +- **npm 传播延迟仍是 ~4–5 分钟**:06:49:10 工作流报 `Published package dsh-better-sidebar@0.19.0`,06:53:53 dist-tags 才可见 `latest = 0.19.0`(版本元数据与 tarball 依次到达,期间 `npm view` / tarball 会 404)。这不是发布失败,别重发。 diff --git a/dsh.plugin.json b/dsh.plugin.json index 51bd46c..54857a2 100644 --- a/dsh.plugin.json +++ b/dsh.plugin.json @@ -1,10 +1,10 @@ { "id": "dsh-external/dsh-better-sidebar", - "version": "0.18.1", + "version": "0.19.0", "main": "./lib/index.js", "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器", "engines": { - "dsh": ">=0.1.2-rc.1" + "dsh": ">=0.1.5-rc.1" }, "contributes": { "tools": [], diff --git a/package.json b/package.json index fdcacf7..27f11da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-better-sidebar", - "version": "0.18.1", + "version": "0.19.0", "description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.", "type": "module", "repository": { @@ -49,6 +49,7 @@ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-slots", "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-sidebar-right", "@deepseek-ai/dsh-client-modules" ], "platform": "web" @@ -88,19 +89,20 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/cordis": "^4.0.2", - "@deepseek-ai/dsh-agent": "^0.1.2-rc.1", - "@deepseek-ai/dsh-client-locale": "^0.1.2-rc.1", - "@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-rc.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-rc.1", - "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-rc.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-rc.1", - "@deepseek-ai/dsh-host-webserver": "^0.1.2-rc.1", - "@deepseek-ai/dsh-invariants": "^0.1.2-rc.1", - "@deepseek-ai/dsh-llm": "^0.1.2-rc.1", - "@deepseek-ai/dsh-session": "^0.1.2-rc.1", - "@deepseek-ai/dsh-settings": "^0.1.2-rc.1", - "@deepseek-ai/dsh-subagent": "^0.1.2-rc.1", - "@deepseek-ai/dsh-tools": "^0.1.2-rc.1", + "@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", "@huanlin/dsh-plugin-better-locale": "^0.1.0", "react": "^18.2.0", "react-dom": "^18.2.0" @@ -108,6 +110,9 @@ "peerDependenciesMeta": { "@huanlin/dsh-plugin-better-locale": { "optional": true + }, + "@deepseek-ai/dsh-client-ui-sidebar-right": { + "optional": true } }, "dependencies": { @@ -144,37 +149,57 @@ }, "devDependencies": { "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "@deepseek-ai/dsh-agent": "0.1.2-rc.1", - "@deepseek-ai/dsh-attachment": "0.1.2-rc.1", - "@deepseek-ai/dsh-client-locale": "0.1.2-rc.1", - "@deepseek-ai/dsh-client-ui-conversation": "0.1.2-rc.1", - "@deepseek-ai/dsh-client-ui-primitives": "0.1.2-rc.1", - "@deepseek-ai/dsh-client-ui-settings": "0.1.2-rc.1", - "@deepseek-ai/dsh-client-ui-slots": "0.1.2-rc.1", - "@deepseek-ai/dsh-code-runtime": "0.1.2-rc.1", - "@deepseek-ai/dsh-host-webserver": "0.1.2-rc.1", - "@deepseek-ai/dsh-invariants": "0.1.2-rc.1", - "@deepseek-ai/dsh-llm": "0.1.2-rc.1", - "@deepseek-ai/dsh-scope": "0.1.2-rc.1", - "@deepseek-ai/dsh-session": "0.1.2-rc.1", - "@deepseek-ai/dsh-session-projection": "0.1.2-rc.1", - "@deepseek-ai/dsh-settings": "0.1.2-rc.1", - "@deepseek-ai/dsh-subagent": "0.1.2-rc.1", - "@deepseek-ai/dsh-system-prompt": "0.1.2-rc.1", - "@deepseek-ai/dsh-tools": "0.1.2-rc.1", - "@deepseek-ai/dsh-user-approval": "0.1.2-rc.1", - "@deepseek-ai/dsh-util-time": "0.1.2-rc.1", + "@deepseek-ai/dsh-agent": "0.1.5-rc.1", + "@deepseek-ai/dsh-agent-loop": "0.1.5-rc.1", + "@deepseek-ai/dsh-attachment": "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-code-runtime": "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-scope": "0.1.5-rc.1", + "@deepseek-ai/dsh-session": "0.1.5-rc.1", + "@deepseek-ai/dsh-session-persistence": "0.1.5-rc.1", + "@deepseek-ai/dsh-session-projection": "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-system-prompt": "0.1.5-rc.1", + "@deepseek-ai/dsh-tools": "0.1.5-rc.1", + "@deepseek-ai/dsh-user-approval": "0.1.5-rc.1", + "@deepseek-ai/dsh-util-time": "0.1.5-rc.1", "@huanlin/dsh-plugin-better-locale": "^0.1.0", "@playwright/test": "^1.62.1", + "@shikijs/langs": "^4.3.1", + "@types/mdast": "^4.0.4", "@types/node": "^24.0.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.1", "@types/ws": "^8.5.10", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^5.5.0", + "anser": "^2.3.5", "cordis": "^4.0.0-rc.8", "jsdom": "^29.1.1", + "katex": "^0.16.47", "lightningcss": "^1.32.0", + "mdast-util-from-markdown": "^2.0.3", + "mdast-util-gfm": "^3.1.0", + "mdast-util-math": "^3.0.0", + "micromark-core-commonmark": "^2.0.3", + "micromark-extension-gfm": "^3.0.0", + "micromark-extension-math": "^3.1.0", + "micromark-factory-space": "^2.0.1", + "micromark-util-character": "^2.1.1", + "micromark-util-classify-character": "^2.0.1", + "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", "tsdown": "^0.22.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f44d498..4002942 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,74 +106,89 @@ importers: specifier: ^1.0.0-rc.5 version: 1.0.0-rc.5(cordis@4.0.0-rc.8) '@deepseek-ai/dsh-agent': - specifier: 0.1.2-rc.1 - version: 0.1.2-rc.1(6f833cf90b9dd3f0d529840b1aebfb48) + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(011efc65c149ca53f6af28580be53483) + '@deepseek-ai/dsh-agent-loop': + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(18dbf52164179c4e7ef8ea2299bce86a) '@deepseek-ai/dsh-attachment': - specifier: 0.1.2-rc.1 - version: 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)) + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)) '@deepseek-ai/dsh-client-locale': - specifier: 0.1.2-rc.1 - version: 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) '@deepseek-ai/dsh-client-ui-conversation': - specifier: 0.1.2-rc.1 - version: 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(typescript@5.6.2) + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) '@deepseek-ai/dsh-client-ui-primitives': - specifier: 0.1.2-rc.1 - version: 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) '@deepseek-ai/dsh-client-ui-settings': - specifier: 0.1.2-rc.1 - version: 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-client-ui-sidebar-right': + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) '@deepseek-ai/dsh-client-ui-slots': - specifier: 0.1.2-rc.1 - version: 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) '@deepseek-ai/dsh-code-runtime': - specifier: 0.1.2-rc.1 - version: 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) '@deepseek-ai/dsh-host-webserver': - specifier: 0.1.2-rc.1 - version: 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) '@deepseek-ai/dsh-invariants': - specifier: 0.1.2-rc.1 - version: 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) '@deepseek-ai/dsh-llm': - specifier: 0.1.2-rc.1 - version: 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) '@deepseek-ai/dsh-scope': - specifier: 0.1.2-rc.1 - version: 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)) + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)) '@deepseek-ai/dsh-session': - specifier: 0.1.2-rc.1 - version: 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))) + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))) + '@deepseek-ai/dsh-session-persistence': + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-timeout@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)) '@deepseek-ai/dsh-session-projection': - specifier: 0.1.2-rc.1 - version: 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-session@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)))) + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)))) '@deepseek-ai/dsh-settings': - specifier: 0.1.2-rc.1 - version: 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-session@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/schemastery@3.18.2) + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/schemastery@3.18.2) '@deepseek-ai/dsh-subagent': - specifier: 0.1.2-rc.1 - version: 0.1.2-rc.1(6241f70df87ac98c48965b657a1f7a60) + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(4255658d50bbc0ac155e99accf1f0524) '@deepseek-ai/dsh-system-prompt': - specifier: 0.1.2-rc.1 - version: 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))) + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))) '@deepseek-ai/dsh-tools': - specifier: 0.1.2-rc.1 - version: 0.1.2-rc.1(de5e1ff2d608fbe22667925341ad1dfc) + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(42bef068bdb77cea11b81bec106cd1d1) '@deepseek-ai/dsh-user-approval': - specifier: 0.1.2-rc.1 - version: 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-agent@0.1.2-rc.1(6f833cf90b9dd3f0d529840b1aebfb48))(@deepseek-ai/dsh-brand@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-session@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-system-prompt@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)))) + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-agent@0.1.5-rc.1(011efc65c149ca53f6af28580be53483))(@deepseek-ai/dsh-brand@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-system-prompt@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)))) '@deepseek-ai/dsh-util-time': - specifier: 0.1.2-rc.1 - version: 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.9.1) '@huanlin/dsh-plugin-better-locale': specifier: ^0.1.0 - version: 0.1.0(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-client-locale@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-client-ui-primitives@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-client-ui-settings@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-client-ui-slots@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 0.1.0(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-client-locale@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-client-ui-settings@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-client-ui-slots@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@playwright/test': specifier: ^1.62.1 version: 1.62.1 + '@shikijs/langs': + specifier: ^4.3.1 + version: 4.4.3 + '@types/mdast': + specifier: ^4.0.4 + version: 4.0.4 '@types/node': specifier: ^24.0.0 version: 24.13.3 @@ -192,6 +207,9 @@ importers: '@xterm/xterm': specifier: ^5.5.0 version: 5.5.0 + anser: + specifier: ^2.3.5 + version: 2.3.5 cordis: specifier: ^4.0.0-rc.8 version: 4.0.0-rc.8(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -207,15 +225,57 @@ importers: jsdom: specifier: ^29.1.1 version: 29.1.1 + katex: + specifier: ^0.16.47 + version: 0.16.47 lightningcss: specifier: ^1.32.0 version: 1.33.0 + mdast-util-from-markdown: + specifier: ^2.0.3 + version: 2.0.3 + mdast-util-gfm: + specifier: ^3.1.0 + version: 3.1.0 + mdast-util-math: + specifier: ^3.0.0 + version: 3.0.0 + micromark-core-commonmark: + specifier: ^2.0.3 + version: 2.0.3 + micromark-extension-gfm: + specifier: ^3.0.0 + version: 3.0.0 + micromark-extension-math: + specifier: ^3.1.0 + version: 3.1.0 + micromark-factory-space: + specifier: ^2.0.1 + version: 2.0.1 + micromark-util-character: + specifier: ^2.1.1 + version: 2.1.1 + micromark-util-classify-character: + specifier: ^2.0.1 + version: 2.0.1 + micromark-util-sanitize-uri: + specifier: ^2.0.1 + version: 2.0.1 + micromark-util-symbol: + specifier: ^2.0.1 + version: 2.0.1 + micromark-util-types: + specifier: ^2.0.2 + version: 2.0.2 react: specifier: ^18.2.0 version: 18.2.0 react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) + shiki: + specifier: ^4.3.1 + version: 4.4.3 tsdown: specifier: ^0.22.2 version: 0.22.14(typescript@5.6.2)(unrun@0.2.39) @@ -458,124 +518,158 @@ packages: '@deepseek-ai/cosmokit@1.8.3': resolution: {integrity: sha512-qBo+ronVM6Eu2WNVJXi8JcMiqZ19T9BRIpV+5qJUFPXjGH/Z0QKcQMC/IZJ7L394YTOtJgcovbk9qP0w2GsBXQ==} - '@deepseek-ai/dsh-agent@0.1.2-rc.1': - resolution: {integrity: sha512-lfaqN34vUCWvbn1kJVHrhfJ6Dvt1HDHCm33ZCpmKkl07/5q6FxWqVv6rOdVX5QnM/9xz/uYiN0nQwUtBg4+Skg==} + '@deepseek-ai/dsh-agent-loop@0.1.5-rc.1': + resolution: {integrity: sha512-FcpsiXMHR7M3UwZtC6CYzhmU9xhvbFuuEQisfUqW7c+G6oVhrX9kg8ytI50jXZempcdmVHZLyNeUcuDmgGtx7Q==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-invariants': ^0.1.2-rc.1 - '@deepseek-ai/dsh-llm': ^0.1.2-rc.1 - '@deepseek-ai/dsh-scope': ^0.1.2-rc.1 - '@deepseek-ai/dsh-session': ^0.1.2-rc.1 - '@deepseek-ai/dsh-session-projection': ^0.1.2-rc.1 - '@deepseek-ai/dsh-system-prompt': ^0.1.2-rc.1 - '@deepseek-ai/dsh-typert-protocol': ^0.1.2-rc.1 + '@deepseek-ai/dsh-agent': ^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-scope': ^0.1.5-rc.1 + '@deepseek-ai/dsh-session': ^0.1.5-rc.1 + '@deepseek-ai/dsh-session-persistence': ^0.1.5-rc.1 + '@deepseek-ai/dsh-session-projection': ^0.1.5-rc.1 + '@deepseek-ai/dsh-settings': ^0.1.5-rc.1 + '@deepseek-ai/dsh-system-prompt': ^0.1.5-rc.1 + '@deepseek-ai/dsh-tools': ^0.1.5-rc.1 - '@deepseek-ai/dsh-attachment@0.1.2-rc.1': - resolution: {integrity: sha512-QISKUjEITusLvAkqPLRn70xDUt+GjjY41pISjtPKYcZ4EGmayzhgZ3VJNYEcC+rbaF44hs2o/0VhOwHA7lyxjw==} + '@deepseek-ai/dsh-agent@0.1.5-rc.1': + resolution: {integrity: sha512-obIPyTSjq1y0Yhasm3mLhK5BW6Ge0VQoRT8FBt0ooLsct2+pFAjgyd7GP3KzFaz7zaEhQJ/NNEmCaU0KOsYutg==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-brand': ^0.1.2-rc.1 + '@deepseek-ai/dsh-invariants': ^0.1.5-rc.1 + '@deepseek-ai/dsh-llm': ^0.1.5-rc.1 + '@deepseek-ai/dsh-scope': ^0.1.5-rc.1 + '@deepseek-ai/dsh-session': ^0.1.5-rc.1 + '@deepseek-ai/dsh-session-projection': ^0.1.5-rc.1 + '@deepseek-ai/dsh-system-prompt': ^0.1.5-rc.1 + '@deepseek-ai/dsh-typert-protocol': ^0.1.5-rc.1 + '@deepseek-ai/dsh-util-values': ^0.1.5-rc.1 - '@deepseek-ai/dsh-brand@0.1.2-rc.1': - resolution: {integrity: sha512-y2RHIag3kzLdY8kTBuSUSTsKzWPscJrysNObXTiNM7stXLpa3jXpshsB1caqGzNx50vYcmHeN1gFVECgx3NZHg==} + '@deepseek-ai/dsh-attachment@0.1.5-rc.1': + resolution: {integrity: sha512-uTBtB/LDlYgPI6i9Ac9jaK/bV1wN8cDTZBUkec80Yg3qMzW6K74wvBv5lPmoiXQgp4q3eOmDNEmodSS2ZxxVdg==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.2 + '@deepseek-ai/dsh-brand': ^0.1.5-rc.1 + + '@deepseek-ai/dsh-brand@0.1.5-rc.1': + resolution: {integrity: sha512-nfEsuSNghhrcvjMnjlPU55Z60K/5FjCRY4ZJtnc0SHR0PgoMl7N9I9+y4t2kDMe3rnEKlH2UgIGIZ/msGOYvpg==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-client-locale@0.1.2-rc.1': - resolution: {integrity: sha512-p34i5WcZ0+b77RKEatGLc6Rh4OgJJmc15kWbBqtCtSI1BxpwZxjDzoNIMTTS3jDGUQqfEd9X7Js9D4uNJDUewg==} + '@deepseek-ai/dsh-chunked-list@0.1.5-rc.1': + resolution: {integrity: sha512-3c/pYWSE++OWdilO8kG7mY9UyhHnwfV0qYQN+Bxwt2YdjqRQZ/6UkwvzNQIAoDqUEWJYwcQUQoLHTrgrrpIxNw==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-client-ui-conversation@0.1.2-rc.1': - resolution: {integrity: sha512-PDrV7twPfmZE5TjdvdVVZMHOla36wCojRC8GJBUp1cdXsWUP+Omk10gxYbH1g5TQPdAWgSL+GktdjondSXMSNQ==} + '@deepseek-ai/dsh-client-locale@0.1.5-rc.1': + resolution: {integrity: sha512-DNzkyudBnGEPPaqRTsBnrr8nast/ZOPSAO21pS1MldK277Wj7Po4bCURKg1cS9QT30Fs4NMeJdIvyV/xNWoMSw==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-client-ui-primitives@0.1.2-rc.1': - resolution: {integrity: sha512-4gZS+tXAHbSxAjP91+lENpxKb49XVKcO80m7ATNNgD6lJHOwbuWVGv9IwEnmQ5ebZvI8Yrp8C1J+jDe1pB9jXw==} + '@deepseek-ai/dsh-client-ui-conversation@0.1.5-rc.1': + resolution: {integrity: sha512-/A+lvO6mc+uTJWJerFyhZeBV+Ez9Sid8+wx7NbIrrg/mdt191IdHqHI9v2sGiIQ/1d+nI3S0YjhOxMH4hGka5A==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-client-ui-settings@0.1.2-rc.1': - resolution: {integrity: sha512-/S16tlS84fggURMqO94HjYbE0xIH82aRJ2E4UupgM9acD3yJ6SCVam17tMcWKnqA6UWJK9llEHhl+KN9JyI/0Q==} + '@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1': + resolution: {integrity: sha512-C6mJOcwg41gD5Yy57QGjZhX/8p8U1kSR4px7tOfLmLIKKXyeHbDytJEKjPKHfh8TkOvpqQ4wA02gQsVe06ithQ==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-client-ui-slots@0.1.2-rc.1': - resolution: {integrity: sha512-wcqn8xwb87RjurSQ0zPoI4K9eYhDyoBjQqg4bByDq8CNynvayHoBPnNWfiAQ8ngyOPE20teL3DMS0gbcfCTqbA==} + '@deepseek-ai/dsh-client-ui-settings@0.1.5-rc.1': + resolution: {integrity: sha512-5O+HJrGuyJL0rNf7ulLI7CbdVozYfKDrAYKXoykAzZZ11EgSEaOrNfRobBhUKMsmXyfbKec7qce9rtG+HOgiuA==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-code-runtime@0.1.2-rc.1': - resolution: {integrity: sha512-dv5UszPJb/AFcarZc2c6h5z/X80bsrSd3bfIOugwNVclSI6P+L3ixseoZKgyks7T0Ap0h6ngXGk2WeoUkmYxnQ==} + '@deepseek-ai/dsh-client-ui-sidebar-right@0.1.5-rc.1': + resolution: {integrity: sha512-JFKsoWRdfX4r++DL2CbuDAgZ6Di1L3siSjcl5z6ilieFfcsN9eUHyOcZN+2WKOQtmpw374ZwrN5Z/i+coO/mCA==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-host-webserver@0.1.2-rc.1': - resolution: {integrity: sha512-QVcaf4qnIa1t215Y8TRiRhqkEz4Lu5/F+KqvWT+4sHOiyWmZr8g1JndqlwP6MBnMQEPmp0I/EKYJ7PWMV32gkA==} + '@deepseek-ai/dsh-client-ui-slots@0.1.5-rc.1': + resolution: {integrity: sha512-xBCdGmV1RdCwWFHCW5m6Y3FVAIHNRR6ppFNeZN4iRpn/osCvnYCF7L+MyjeWExyjC71Xl/Rh06Kj8/1gtbb/wg==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-invariants@0.1.2-rc.1': - resolution: {integrity: sha512-zSjuFJYcEB94kPwn7SicqAV1dfVWZkjt1I8luDboxtjfp5CN6ziwJhLv3KaB+Dh9tgkyGY4mvo886W/HhE9L9g==} + '@deepseek-ai/dsh-code-runtime@0.1.5-rc.1': + resolution: {integrity: sha512-DfqzMEQW42lVQjjQ6FCEmtzAuYGle1grxqrRPqdXhkDYe4aTSLJLEcqn7/BYZSI64/G1XQYvdS+jkYxbymbv3A==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-llm@0.1.2-rc.1': - resolution: {integrity: sha512-7VYsha5AXsVLnsAwYJffWXz9bwUbElw8i5N8tlTSdai9Bupk3sMbsotzPf8ZbsuGAxQYErahMwQwgGEu4qZO6g==} + '@deepseek-ai/dsh-host-webserver@0.1.5-rc.1': + resolution: {integrity: sha512-5kOu9kb0AuRN60/zwPTRcki801ozgnWAFwS1QtQ4ZNgCYIbAiU8gwJHY1//qEpUOuHS+26k+Tqq5/WCJmLGE6Q==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-scope@0.1.2-rc.1': - resolution: {integrity: sha512-GHwbzWrGbmE0ZGzAZsXBudpyCM9ENCpXxlKODIaPFbIWhRePh6nH2jW47TAOqCIsPPedWC2p3w8Tx5WAa3I/2A==} + '@deepseek-ai/dsh-invariants@0.1.5-rc.1': + resolution: {integrity: sha512-iXhJJ3r34NHFEWiuc5pxXlU19hPfNclvH9Ll0X7CzQ39Oh/4UlfO3DBlLpBU7T9fE/y7/UkuI1aRt+dNeiVQsQ==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-invariants': ^0.1.2-rc.1 - '@deepseek-ai/dsh-session-projection@0.1.2-rc.1': - resolution: {integrity: sha512-qVWlIzPm4u9vzQ5G/QtE6jeSJ3b3b42hWVJj664BurEZlHbAfdqkhYc1QsoBU+5k5kUEJFHh07z8HxuXA9kTnQ==} + '@deepseek-ai/dsh-llm@0.1.5-rc.1': + resolution: {integrity: sha512-KPKJFTNLjURphuF4NlS8DRK94CUYL/dKB8Hzg/22jtxAFO2paX3ifL0vhdPq9xPbcyplaF7LYlf0+3+pXFTnTg==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-session': ^0.1.2-rc.1 - '@deepseek-ai/dsh-session@0.1.2-rc.1': - resolution: {integrity: sha512-jRGNPTbQcvIx1F2MVmmkoiHLgpC3Btqo1wkl/3JDLlOWRVWKOuaC+5fQiMrFPOYfno6YiX/c2UvmA0td8qB92w==} + '@deepseek-ai/dsh-scope@0.1.5-rc.1': + resolution: {integrity: sha512-HU6AGzCCWSQUS9KoDBAPkyQqgUVp+iI9P5Zn0qzLJv3IOdt6cYvxgjt/rORryuQUR9AUj7CGFO3OYw833H7HWA==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-scope': ^0.1.2-rc.1 + '@deepseek-ai/dsh-invariants': ^0.1.5-rc.1 - '@deepseek-ai/dsh-settings@0.1.2-rc.1': - resolution: {integrity: sha512-wMmJ2w5S6I7hgmgPppuAVemltEcTmAa8gvii7fk4T2KGVCuBYgig8xPwQ8Lp2ukLZSzzE6ZVCQxhdYZyJX/USA==} + '@deepseek-ai/dsh-session-persistence@0.1.5-rc.1': + resolution: {integrity: sha512-ZOFdFdsX5w4lWln1CO2ZzdnibEIOoPu58rlafw4xsLaPoR5mnceVnIuGIYr8qqaB7iv2f9ZLNaGtyHT/Wgpv0Q==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-brand': ^0.1.2-rc.1 - '@deepseek-ai/dsh-invariants': ^0.1.2-rc.1 - '@deepseek-ai/dsh-session': ^0.1.2-rc.1 + '@deepseek-ai/dsh-brand': ^0.1.5-rc.1 + '@deepseek-ai/dsh-session': ^0.1.5-rc.1 + '@deepseek-ai/dsh-timeout': ^0.1.5-rc.1 + + '@deepseek-ai/dsh-session-projection@0.1.5-rc.1': + resolution: {integrity: sha512-dwDS3ite9I1oOAPneA87ieHBd+szFNrTeMNF4XHRxexMbBWxNZUv62h714UMNlk2niGKsAKE56PzwlnWHmGwGg==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.2 + '@deepseek-ai/dsh-session': ^0.1.5-rc.1 + + '@deepseek-ai/dsh-session@0.1.5-rc.1': + resolution: {integrity: sha512-0YBBrzkCbVEJolS/OpD0DZMSozmYxUTZopiG76MXInjOFBW9J4ca1a8WUjJjqelP1nJTmWGKXp92HcvNEny0Dg==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.2 + '@deepseek-ai/dsh-scope': ^0.1.5-rc.1 + + '@deepseek-ai/dsh-settings@0.1.5-rc.1': + resolution: {integrity: sha512-9t6JlHwnMu7qTHpMVVRLyzfi7yWKlDozmh7Xbjjd5mmzY+dSdKTPsoRGSdGgTLvBMQBi0X2O8Kj2moDp30XKGg==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.2 + '@deepseek-ai/dsh-brand': ^0.1.5-rc.1 + '@deepseek-ai/dsh-invariants': ^0.1.5-rc.1 + '@deepseek-ai/dsh-session': ^0.1.5-rc.1 '@deepseek-ai/schemastery': ^3.18.2 - '@deepseek-ai/dsh-subagent@0.1.2-rc.1': - resolution: {integrity: sha512-GhRsymKb1uYbc7OwHh8WTdSXqAcx/KHFJwYX4BYJk90dsOkVx9ccBq6QoJfDYja34s1KCyv2UO/8MUtrzBsITw==} + '@deepseek-ai/dsh-subagent@0.1.5-rc.1': + resolution: {integrity: sha512-3XQxzPkKtjhKcn2TgC1myS4qqT5s+Ya0pSLcp0dP36eS9qGLYkg/F7nM48H/xRsNKari+CIcTkVKn9xvuEz6rg==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-agent': ^0.1.2-rc.1 - '@deepseek-ai/dsh-agent-presets': ^0.1.2-rc.1 - '@deepseek-ai/dsh-attachment': ^0.1.2-rc.1 - '@deepseek-ai/dsh-invariants': ^0.1.2-rc.1 - '@deepseek-ai/dsh-jobs': ^0.1.2-rc.1 - '@deepseek-ai/dsh-llm': ^0.1.2-rc.1 - '@deepseek-ai/dsh-sandbox': ^0.1.2-rc.1 - '@deepseek-ai/dsh-sandbox-policy': ^0.1.2-rc.1 - '@deepseek-ai/dsh-scope': ^0.1.2-rc.1 - '@deepseek-ai/dsh-session': ^0.1.2-rc.1 - '@deepseek-ai/dsh-session-persistence': ^0.1.2-rc.1 - '@deepseek-ai/dsh-session-projection': ^0.1.2-rc.1 - '@deepseek-ai/dsh-session-projection-cache': ^0.1.2-rc.1 - '@deepseek-ai/dsh-session-query': ^0.1.2-rc.1 - '@deepseek-ai/dsh-system-prompt': ^0.1.2-rc.1 - '@deepseek-ai/dsh-tools': ^0.1.2-rc.1 - '@deepseek-ai/dsh-typert-protocol': ^0.1.2-rc.1 - '@deepseek-ai/dsh-user-approval': ^0.1.2-rc.1 - '@deepseek-ai/dsh-util-time': ^0.1.2-rc.1 + '@deepseek-ai/dsh-agent': ^0.1.5-rc.1 + '@deepseek-ai/dsh-agent-presets': ^0.1.5-rc.1 + '@deepseek-ai/dsh-attachment': ^0.1.5-rc.1 + '@deepseek-ai/dsh-invariants': ^0.1.5-rc.1 + '@deepseek-ai/dsh-jobs': ^0.1.5-rc.1 + '@deepseek-ai/dsh-llm': ^0.1.5-rc.1 + '@deepseek-ai/dsh-sandbox': ^0.1.5-rc.1 + '@deepseek-ai/dsh-sandbox-policy': ^0.1.5-rc.1 + '@deepseek-ai/dsh-scope': ^0.1.5-rc.1 + '@deepseek-ai/dsh-session': ^0.1.5-rc.1 + '@deepseek-ai/dsh-session-persistence': ^0.1.5-rc.1 + '@deepseek-ai/dsh-session-projection': ^0.1.5-rc.1 + '@deepseek-ai/dsh-session-projection-cache': ^0.1.5-rc.1 + '@deepseek-ai/dsh-session-query': ^0.1.5-rc.1 + '@deepseek-ai/dsh-system-prompt': ^0.1.5-rc.1 + '@deepseek-ai/dsh-tools': ^0.1.5-rc.1 + '@deepseek-ai/dsh-typert-protocol': ^0.1.5-rc.1 + '@deepseek-ai/dsh-user-approval': ^0.1.5-rc.1 + '@deepseek-ai/dsh-util-time': ^0.1.5-rc.1 peerDependenciesMeta: '@deepseek-ai/dsh-agent-presets': optional: true @@ -596,61 +690,61 @@ packages: '@deepseek-ai/dsh-user-approval': optional: true - '@deepseek-ai/dsh-system-prompt@0.1.2-rc.1': - resolution: {integrity: sha512-7W93PZKIk4CHvjZGgLIxrrEKpk+6t8nQXje1vKR5vG73dfXH0t1MJ5WWH/fngadYc7c6KzM84ihQ66tJqJqyNQ==} + '@deepseek-ai/dsh-system-prompt@0.1.5-rc.1': + resolution: {integrity: sha512-RAdO9biQoga1vAVTQY9J7THexiOE1FOd1Nli021MQt+Zf73c83BSd2DwXb0WDilLaMeSxZPaIRRqoXpJlpmLIA==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-invariants': ^0.1.2-rc.1 - '@deepseek-ai/dsh-llm': ^0.1.2-rc.1 - '@deepseek-ai/dsh-scope': ^0.1.2-rc.1 + '@deepseek-ai/dsh-invariants': ^0.1.5-rc.1 + '@deepseek-ai/dsh-llm': ^0.1.5-rc.1 + '@deepseek-ai/dsh-scope': ^0.1.5-rc.1 - '@deepseek-ai/dsh-timeout@0.1.2-rc.1': - resolution: {integrity: sha512-llz11yxWeJB8suKCa6hRLjNBWVaPn3Fbd0XoBS0bkZLhPFzO1mDorx/XMca2GhI7XF9A86CURQcDt+aGjjvqJA==} + '@deepseek-ai/dsh-timeout@0.1.5-rc.1': + resolution: {integrity: sha512-BcV4y/uCIm82VlIwuGsbLjAZXOxRBmjwusi637MV25hJhhGuWaKoMqAa0D/Lcfqu8hU+g3vgGJdpQvhHnG8sgQ==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-tools@0.1.2-rc.1': - resolution: {integrity: sha512-W9kUio00s7WbM8kEwniyd4hfb3CeUxVczsjXbOcKtakfiTywNLeyRWkpx2fvwRAH2rBfg3qCgczVfS0DOw1csg==} + '@deepseek-ai/dsh-tools@0.1.5-rc.1': + resolution: {integrity: sha512-I5AUxKTqUrC0nvRO4UcpU+f65P+nKs5BUrS2nqZehhFZ2rVxhUAJ7YdORcY6pVkBTB15nPr5gK0WPgwyfS217w==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-agent': ^0.1.2-rc.1 - '@deepseek-ai/dsh-code-runtime': ^0.1.2-rc.1 - '@deepseek-ai/dsh-invariants': ^0.1.2-rc.1 - '@deepseek-ai/dsh-llm': ^0.1.2-rc.1 - '@deepseek-ai/dsh-scope': ^0.1.2-rc.1 - '@deepseek-ai/dsh-session': ^0.1.2-rc.1 - '@deepseek-ai/dsh-system-prompt': ^0.1.2-rc.1 - '@deepseek-ai/dsh-user-approval': ^0.1.2-rc.1 + '@deepseek-ai/dsh-agent': ^0.1.5-rc.1 + '@deepseek-ai/dsh-code-runtime': ^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-scope': ^0.1.5-rc.1 + '@deepseek-ai/dsh-session': ^0.1.5-rc.1 + '@deepseek-ai/dsh-system-prompt': ^0.1.5-rc.1 + '@deepseek-ai/dsh-user-approval': ^0.1.5-rc.1 - '@deepseek-ai/dsh-typert-protocol@0.1.2-rc.1': - resolution: {integrity: sha512-vBWD0NpriPd96ltbWaAw+iKeWFIEjiAcCWYtmqEcV1Ms+FaV02usjjm7LCmhSQhMZpBSBc7EZUX0spGlpjI1bA==} + '@deepseek-ai/dsh-typert-protocol@0.1.5-rc.1': + resolution: {integrity: sha512-HBxnSnvjiYPlir6An+/W+66dTRQnwpbNUbtOH6e5LHuV4CcHL5yrimTbMI04olJM9w2U9FyfwDOzmW6uqWjAew==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-user-approval@0.1.2-rc.1': - resolution: {integrity: sha512-oYkLE4a/TwqVNi299pUf/QP1Yku6KScdUCTUyYbjaRBcN+/pXPpHikl2aoE2thH9D0uT/Kj6T2kz+wDDcFd9Xg==} + '@deepseek-ai/dsh-user-approval@0.1.5-rc.1': + resolution: {integrity: sha512-fSxEBvHQnozIh5HV31t2BNodYzyv7iE2srna7p5k0Y/3R9c6DdoZyQZ9momcN9gloraq8HK5+cvrYHzgc4LYPQ==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-agent': ^0.1.2-rc.1 - '@deepseek-ai/dsh-brand': ^0.1.2-rc.1 - '@deepseek-ai/dsh-invariants': ^0.1.2-rc.1 - '@deepseek-ai/dsh-llm': ^0.1.2-rc.1 - '@deepseek-ai/dsh-scope': ^0.1.2-rc.1 - '@deepseek-ai/dsh-session': ^0.1.2-rc.1 - '@deepseek-ai/dsh-system-prompt': ^0.1.2-rc.1 + '@deepseek-ai/dsh-agent': ^0.1.5-rc.1 + '@deepseek-ai/dsh-brand': ^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-scope': ^0.1.5-rc.1 + '@deepseek-ai/dsh-session': ^0.1.5-rc.1 + '@deepseek-ai/dsh-system-prompt': ^0.1.5-rc.1 - '@deepseek-ai/dsh-util-crypto@0.1.2-rc.1': - resolution: {integrity: sha512-gE8FznQ1hkknw9QMrHNSlm3UEyRYuj3MLVvlJ6NyH1kmOsdjnbrVbSs+Pn72k2oIV4Fiyu3Umg0sLznUEcsiXw==} + '@deepseek-ai/dsh-util-crypto@0.1.5-rc.1': + resolution: {integrity: sha512-8ZosLrwbXdsWRlZ/0Yu85GMhKbFHqGD+ayiFzABXGb3sxfG6nbQQWKtv1sRr77wJ6wYvM2R9rPOg+EDWkGocqQ==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-util-time@0.1.2-rc.1': - resolution: {integrity: sha512-OoudDLoYothbZKDbjO6kFu3NLgAnglIS8NFkg5fjnBtz8LGwplN8w7kRURLIrZtH38HknYhpDsdJh8LZai3U6A==} + '@deepseek-ai/dsh-util-time@0.1.5-rc.1': + resolution: {integrity: sha512-HXfJqKymuFv052hHzpHNvrHAcgMbx1BG0o9PiEGkqel9k0fXRBmhWGqrROebZxe5gpIotlVhq7QAXd36p/dyzA==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-util-values@0.1.2-rc.1': - resolution: {integrity: sha512-FQdgoK+KYVR0pBQb9fTsajvGb3gzUxEzJQEYnFOt+Qdwc4nkxJOPwJJdIvXuqvZaQ33DaOoqpnpcr2p5HpLu4g==} + '@deepseek-ai/dsh-util-values@0.1.5-rc.1': + resolution: {integrity: sha512-iHH62b+Mc0ueLHn1OQGLOC6+WMqjyK2+wxCkaiQGoGHBAyRGQBnBWiFAOGxp2xIElCXsc1mU1nrkN8D6jppYQg==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 @@ -802,94 +896,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@lexical/clipboard@0.49.0': - resolution: {integrity: sha512-AVKj21xH1qU7JAFA/v0hCoafa+Yti1I7cHG+JQIgc/EqCtP8ePeJyIfTPNN/tXskoCdq++HewQoiSXRwwlocVg==} - peerDependencies: - typescript: '>=5.2' - peerDependenciesMeta: - typescript: - optional: true - - '@lexical/dragon@0.49.0': - resolution: {integrity: sha512-62/4DP5qyX/l4Yf5qRyyQrs9BV725eRU3OmLUW6g7T5xrcHXxAo7tia/NvqjqvXdpvQzyHjWgsy7dMitGITUsw==} - peerDependencies: - typescript: '>=5.2' - peerDependenciesMeta: - typescript: - optional: true - - '@lexical/extension@0.49.0': - resolution: {integrity: sha512-Wv0VsuqxorbxHCK4ms1PwAu6cXIGNLtflq66auF+zwxOtBprkSFV8VzzzdKLOYD2admP0VK6EqVCeGXFK1GggA==} - peerDependencies: - typescript: '>=5.2' - peerDependenciesMeta: - typescript: - optional: true - - '@lexical/history@0.49.0': - resolution: {integrity: sha512-uQdtEd34gIJklXNSdHS2Wko1zxx1xUMVXbiodcLO6a3GeFTE5bKnx6af1zGX78KpYhUQYnHWorKuUvtdZlJPaA==} - peerDependencies: - typescript: '>=5.2' - peerDependenciesMeta: - typescript: - optional: true - - '@lexical/html@0.49.0': - resolution: {integrity: sha512-NQqAydKzRjQl7Jx+bTTyC2iuz5uhuDaQX0SSPrdfgaFDCPjqQEejcBkCt1QXnu1CkbVHutZKVGVzljx40Y6y+Q==} - peerDependencies: - typescript: '>=5.2' - peerDependenciesMeta: - typescript: - optional: true - - '@lexical/internal@0.49.0': - resolution: {integrity: sha512-s+XjPC7Qb39A/Xx9ahcz1s69CPix4ultaqyW+MDG0AXYW2quDr7m0USqReKIAiuoLIWtGN5HW8BwV2Y6t4qr6Q==} - peerDependencies: - typescript: '>=5.2' - peerDependenciesMeta: - typescript: - optional: true - - '@lexical/list@0.49.0': - resolution: {integrity: sha512-zs6wYkxakDRcJO0KmwrPPHgeLLIxzjD+P2CRu+scJCHRuA5e2iSw2iFjH5n/LlWBrlnPMSjeQse4QqsRy9nnqA==} - peerDependencies: - typescript: '>=5.2' - peerDependenciesMeta: - typescript: - optional: true - - '@lexical/plain-text@0.49.0': - resolution: {integrity: sha512-l7IuUj9n9CtFfz4Fz6zU6mmO+VkcnvyyebABx+lHevvTa7B6YI5PPVgABFfzdyPanyW/FGs7qpKBf59inAqbkg==} - peerDependencies: - typescript: '>=5.2' - peerDependenciesMeta: - typescript: - optional: true - - '@lexical/selection@0.49.0': - resolution: {integrity: sha512-08Vd1+VoC6YnztWOFWVsqF/Hxw5EP8qeL1c7t3+rVCV8revLzXxdQw+vbPX3tgM4fmjOBDUXk6Ws1+rTtsqjcQ==} - peerDependencies: - typescript: '>=5.2' - peerDependenciesMeta: - typescript: - optional: true - - '@lexical/text@0.49.0': - resolution: {integrity: sha512-mowedvbvx0HDaW+ymVdYmWVhueqgauhhqIWaCtbJj33tPk8pOu1BB0pZuJQbOB+1bDnFiZhkJV8W/CvR2M9lEA==} - peerDependencies: - typescript: '>=5.2' - peerDependenciesMeta: - typescript: - optional: true - - '@lexical/utils@0.49.0': - resolution: {integrity: sha512-Jaa6DERBqxiFOFa49VPRV1WOb7mzRbMZ5U+v+RFagjzTmBhfxDmEkbQQ9nYTU3n3DPfdT8Hkyffextmw04etXg==} - peerDependencies: - typescript: '>=5.2' - peerDependenciesMeta: - typescript: - optional: true - '@lezer/common@1.5.2': resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} @@ -966,9 +972,6 @@ packages: engines: {node: '>=20'} hasBin: true - '@preact/signals-core@1.14.4': - resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==} - '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -1412,8 +1415,8 @@ packages: resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@ungap/structured-clone@1.3.3': - resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} @@ -2267,14 +2270,6 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lexical@0.49.0: - resolution: {integrity: sha512-9V1ZIzGpJEd8rIN+nN7veL4fW4fFWbS66Un4JNqSZB4D5t9euzN9+3+jEXy83FjNjNy0MiiUI3+DQaGCLYko0w==} - peerDependencies: - typescript: '>=5.2' - peerDependenciesMeta: - typescript: - optional: true - lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -2418,11 +2413,8 @@ packages: mdast-util-to-hast@13.2.1: resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} - mdast-util-to-markdown@2.0.0: - resolution: {integrity: sha512-Ov3aWCYpb31/SkHNRlREvSZsF9ETBW/rXw4PooawZO8qy2MviDq4TI6kxX6zFmGa/zUX36STKIC/IpASQk596w==} - - mdast-util-to-markdown@2.1.0: - resolution: {integrity: sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ==} + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} @@ -2460,56 +2452,56 @@ packages: micromark-extension-math@3.1.0: resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} - micromark-factory-destination@2.0.0: - resolution: {integrity: sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==} + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} - micromark-factory-label@2.0.0: - resolution: {integrity: sha512-RR3i96ohZGde//4WSe/dJsxOX6vxIg9TimLAS3i4EhBAFx8Sm5SmqVfR8E87DPSR31nEAjZfbt91OMZWcNgdZw==} + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} micromark-factory-space@2.0.1: resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} - micromark-factory-title@2.0.0: - resolution: {integrity: sha512-jY8CSxmpWLOxS+t8W+FG3Xigc0RDQA9bKMY/EwILvsesiRniiVMejYTE4wumNc2f4UbAa4WsHqe3J1QS1sli+A==} + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} - micromark-factory-whitespace@2.0.0: - resolution: {integrity: sha512-28kbwaBjc5yAI1XadbdPYHX/eDnqaUFVikLwrO7FDnKG7lpgxnvk/XGRhX/PN0mOZ+dBSZ+LgunHS+6tYQAzhA==} + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} micromark-util-character@2.1.1: resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} - micromark-util-chunked@2.0.0: - resolution: {integrity: sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg==} + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} micromark-util-classify-character@2.0.1: resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} - micromark-util-combine-extensions@2.0.0: - resolution: {integrity: sha512-vZZio48k7ON0fVS3CUgFatWHoKbbLTK/rT7pzpJ4Bjp5JjkZeasRfrS9wsBdDJK2cJLHMckXZdzPSSr1B8a4oQ==} + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} - micromark-util-decode-numeric-character-reference@2.0.0: - resolution: {integrity: sha512-pIgcsGxpHEtTG/rPJRz/HOLSqp5VTuIIjXlPI+6JSDlK2oljApusG6KzpS8AF0ENUMCHlC/IBb5B9xdFiVlm5Q==} + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} micromark-util-decode-string@2.0.1: resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} - micromark-util-encode@2.0.0: - resolution: {integrity: sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==} + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} - micromark-util-html-tag-name@2.0.0: - resolution: {integrity: sha512-xNn4Pqkj2puRhKdKTm8t1YHC/BAjx6CEwRFXntTaRf/x16aqka6ouVoutm+QdkISTlT7e2zU7U4ZdlDLJd2Mcw==} + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} - micromark-util-normalize-identifier@2.0.0: - resolution: {integrity: sha512-2xhYT0sfo85FMrUPtHcPo2rrp1lwbDEEzpx7jiH2xXJLqBuy4H0GgXk5ToU8IEwoROtXuL8ND0ttVa4rNqYK3w==} + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} - micromark-util-resolve-all@2.0.0: - resolution: {integrity: sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA==} + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} micromark-util-sanitize-uri@2.0.1: resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} - micromark-util-subtokenize@2.0.0: - resolution: {integrity: sha512-vc93L1t+gpR3p8jxeVdaYlbV2jTYteDje19rNSS/H5dlhxUYll5Fy6vJ2cDwP8RnsXi818yGty1ayP55y3W6fg==} + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} micromark-util-symbol@2.0.1: resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} @@ -2517,8 +2509,8 @@ packages: micromark-util-types@2.0.2: resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} - micromark@4.0.0: - resolution: {integrity: sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==} + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} @@ -3506,83 +3498,81 @@ snapshots: '@deepseek-ai/cosmokit@1.8.3': {} - '@deepseek-ai/dsh-agent@0.1.2-rc.1(6f833cf90b9dd3f0d529840b1aebfb48)': + '@deepseek-ai/dsh-agent-loop@0.1.5-rc.1(18dbf52164179c4e7ef8ea2299bce86a)': dependencies: '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-invariants': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-llm': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-scope': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)) - '@deepseek-ai/dsh-session': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))) - '@deepseek-ai/dsh-session-projection': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-session@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)))) - '@deepseek-ai/dsh-system-prompt': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))) - '@deepseek-ai/dsh-typert-protocol': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-agent': 0.1.5-rc.1(011efc65c149ca53f6af28580be53483) + '@deepseek-ai/dsh-brand': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-invariants': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-llm': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-scope': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)) + '@deepseek-ai/dsh-session': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))) + '@deepseek-ai/dsh-session-persistence': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-timeout@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)) + '@deepseek-ai/dsh-session-projection': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)))) + '@deepseek-ai/dsh-settings': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/schemastery@3.18.2) + '@deepseek-ai/dsh-system-prompt': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))) + '@deepseek-ai/dsh-tools': 0.1.5-rc.1(42bef068bdb77cea11b81bec106cd1d1) + '@deepseek-ai/dsh-util-values': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/schemastery': 3.18.2 + zod: 4.4.3 - '@deepseek-ai/dsh-attachment@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))': + '@deepseek-ai/dsh-agent@0.1.5-rc.1(011efc65c149ca53f6af28580be53483)': dependencies: '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-brand': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-invariants': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-llm': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-scope': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)) + '@deepseek-ai/dsh-session': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))) + '@deepseek-ai/dsh-session-projection': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)))) + '@deepseek-ai/dsh-system-prompt': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))) + '@deepseek-ai/dsh-typert-protocol': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-util-values': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-brand@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-attachment@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))': + dependencies: + '@deepseek-ai/cordis': 4.0.2 + '@deepseek-ai/dsh-brand': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + + '@deepseek-ai/dsh-brand@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)': dependencies: '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-client-locale@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-chunked-list@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)': + dependencies: + '@deepseek-ai/cordis': 4.0.2 + zod: 4.4.3 + + '@deepseek-ai/dsh-client-locale@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)': dependencies: '@deepseek-ai/cordis': 4.0.2 '@deepseek-ai/schemastery': 3.18.2 - '@deepseek-ai/dsh-client-ui-conversation@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(typescript@5.6.2)': + '@deepseek-ai/dsh-client-ui-conversation@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)': dependencies: '@deepseek-ai/cordis': 4.0.2 '@deepseek-ai/schemastery': 3.18.2 - '@lexical/history': 0.49.0(typescript@5.6.2) - '@lexical/plain-text': 0.49.0(typescript@5.6.2) - '@lexical/text': 0.49.0(typescript@5.6.2) - '@lexical/utils': 0.49.0(typescript@5.6.2) - clsx: 2.1.1 - lexical: 0.49.0(typescript@5.6.2) - transitivePeerDependencies: - - typescript - '@deepseek-ai/dsh-client-ui-primitives@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)': - dependencies: - '@deepseek-ai/cordis': 4.0.2 - '@shikijs/langs': 4.4.3 - '@types/mdast': 4.0.4 - anser: 2.3.5 - clsx: 2.1.1 - katex: 0.16.47 - mdast-util-from-markdown: 2.0.3 - mdast-util-gfm: 3.1.0 - mdast-util-math: 3.0.0 - micromark-core-commonmark: 2.0.3 - micromark-extension-gfm: 3.0.0 - micromark-extension-math: 3.1.0 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-classify-character: 2.0.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - shiki: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@deepseek-ai/dsh-client-ui-settings@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)': dependencies: '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-client-ui-slots@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-client-ui-settings@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)': dependencies: '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-code-runtime@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-client-ui-sidebar-right@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)': dependencies: '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-host-webserver@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-client-ui-slots@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)': + dependencies: + '@deepseek-ai/cordis': 4.0.2 + + '@deepseek-ai/dsh-code-runtime@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)': + dependencies: + '@deepseek-ai/cordis': 4.0.2 + + '@deepseek-ai/dsh-host-webserver@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)': dependencies: '@deepseek-ai/cordis': 4.0.2 '@deepseek-ai/schemastery': 3.18.2 @@ -3591,122 +3581,132 @@ snapshots: transitivePeerDependencies: - supports-color - '@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)': dependencies: '@deepseek-ai/cordis': 4.0.2 '@deepseek-ai/schemastery': 3.18.2 - '@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-llm@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)': dependencies: '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-brand': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-timeout': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-typert-protocol': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-util-crypto': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-util-values': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-brand': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-timeout': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-typert-protocol': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-util-crypto': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-util-values': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) '@deepseek-ai/schemastery': 3.18.2 zod: 4.4.3 - '@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))': + '@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))': dependencies: '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-invariants': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-invariants': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-session-projection@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-session@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))))': + '@deepseek-ai/dsh-session-persistence@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-timeout@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))': dependencies: '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-session': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))) + '@deepseek-ai/dsh-brand': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-session': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))) + '@deepseek-ai/dsh-timeout': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-util-values': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + + '@deepseek-ai/dsh-session-projection@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))))': + dependencies: + '@deepseek-ai/cordis': 4.0.2 + '@deepseek-ai/dsh-session': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))) zod: 4.4.3 - '@deepseek-ai/dsh-session@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)))': + '@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)))': dependencies: '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-brand': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-llm': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-scope': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)) - '@deepseek-ai/dsh-util-values': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-brand': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-llm': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-scope': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)) + '@deepseek-ai/dsh-util-values': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-settings@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-session@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/schemastery@3.18.2)': + '@deepseek-ai/dsh-settings@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/schemastery@3.18.2)': dependencies: '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-brand': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-invariants': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-session': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))) - '@deepseek-ai/dsh-util-values': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-brand': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-invariants': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-session': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))) + '@deepseek-ai/dsh-util-values': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) '@deepseek-ai/schemastery': 3.18.2 - '@deepseek-ai/dsh-subagent@0.1.2-rc.1(6241f70df87ac98c48965b657a1f7a60)': + '@deepseek-ai/dsh-subagent@0.1.5-rc.1(4255658d50bbc0ac155e99accf1f0524)': dependencies: '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-agent': 0.1.2-rc.1(6f833cf90b9dd3f0d529840b1aebfb48) - '@deepseek-ai/dsh-attachment': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)) - '@deepseek-ai/dsh-brand': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-invariants': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-llm': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-scope': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)) - '@deepseek-ai/dsh-session': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))) - '@deepseek-ai/dsh-system-prompt': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))) - '@deepseek-ai/dsh-tools': 0.1.2-rc.1(de5e1ff2d608fbe22667925341ad1dfc) - '@deepseek-ai/dsh-typert-protocol': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-util-time': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-util-values': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-agent': 0.1.5-rc.1(011efc65c149ca53f6af28580be53483) + '@deepseek-ai/dsh-attachment': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)) + '@deepseek-ai/dsh-brand': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-chunked-list': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-invariants': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-llm': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-scope': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)) + '@deepseek-ai/dsh-session': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))) + '@deepseek-ai/dsh-system-prompt': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))) + '@deepseek-ai/dsh-tools': 0.1.5-rc.1(42bef068bdb77cea11b81bec106cd1d1) + '@deepseek-ai/dsh-typert-protocol': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-util-time': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-util-values': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) zod: 4.4.3 optionalDependencies: - '@deepseek-ai/dsh-session-projection': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-session@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)))) - '@deepseek-ai/dsh-user-approval': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-agent@0.1.2-rc.1(6f833cf90b9dd3f0d529840b1aebfb48))(@deepseek-ai/dsh-brand@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-session@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-system-prompt@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)))) + '@deepseek-ai/dsh-session-persistence': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-timeout@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)) + '@deepseek-ai/dsh-session-projection': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)))) + '@deepseek-ai/dsh-user-approval': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-agent@0.1.5-rc.1(011efc65c149ca53f6af28580be53483))(@deepseek-ai/dsh-brand@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-system-prompt@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)))) - '@deepseek-ai/dsh-system-prompt@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)))': + '@deepseek-ai/dsh-system-prompt@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)))': dependencies: '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-invariants': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-llm': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-scope': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)) + '@deepseek-ai/dsh-invariants': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-llm': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-scope': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)) '@deepseek-ai/schemastery': 3.18.2 - '@deepseek-ai/dsh-timeout@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-timeout@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)': dependencies: '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-tools@0.1.2-rc.1(de5e1ff2d608fbe22667925341ad1dfc)': + '@deepseek-ai/dsh-tools@0.1.5-rc.1(42bef068bdb77cea11b81bec106cd1d1)': dependencies: '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-agent': 0.1.2-rc.1(6f833cf90b9dd3f0d529840b1aebfb48) - '@deepseek-ai/dsh-brand': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-code-runtime': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-invariants': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-llm': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-scope': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)) - '@deepseek-ai/dsh-session': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))) - '@deepseek-ai/dsh-system-prompt': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))) - '@deepseek-ai/dsh-user-approval': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-agent@0.1.2-rc.1(6f833cf90b9dd3f0d529840b1aebfb48))(@deepseek-ai/dsh-brand@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-session@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-system-prompt@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)))) - '@deepseek-ai/dsh-util-values': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-agent': 0.1.5-rc.1(011efc65c149ca53f6af28580be53483) + '@deepseek-ai/dsh-brand': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-code-runtime': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-invariants': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-llm': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-scope': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)) + '@deepseek-ai/dsh-session': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))) + '@deepseek-ai/dsh-system-prompt': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))) + '@deepseek-ai/dsh-user-approval': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-agent@0.1.5-rc.1(011efc65c149ca53f6af28580be53483))(@deepseek-ai/dsh-brand@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-system-prompt@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)))) + '@deepseek-ai/dsh-util-values': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) '@deepseek-ai/schemastery': 3.18.2 - '@deepseek-ai/dsh-typert-protocol@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-typert-protocol@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)': dependencies: '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-user-approval@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-agent@0.1.2-rc.1(6f833cf90b9dd3f0d529840b1aebfb48))(@deepseek-ai/dsh-brand@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-session@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-system-prompt@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))))': + '@deepseek-ai/dsh-user-approval@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-agent@0.1.5-rc.1(011efc65c149ca53f6af28580be53483))(@deepseek-ai/dsh-brand@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-system-prompt@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))))': dependencies: '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-agent': 0.1.2-rc.1(6f833cf90b9dd3f0d529840b1aebfb48) - '@deepseek-ai/dsh-brand': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-invariants': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-llm': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-scope': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)) - '@deepseek-ai/dsh-session': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))) - '@deepseek-ai/dsh-system-prompt': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))) + '@deepseek-ai/dsh-agent': 0.1.5-rc.1(011efc65c149ca53f6af28580be53483) + '@deepseek-ai/dsh-brand': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-invariants': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-llm': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-scope': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)) + '@deepseek-ai/dsh-session': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))) + '@deepseek-ai/dsh-system-prompt': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))) '@deepseek-ai/schemastery': 3.18.2 - '@deepseek-ai/dsh-util-crypto@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-util-crypto@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)': dependencies: '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-util-time@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-util-time@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)': dependencies: '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-util-values@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-util-values@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)': dependencies: '@deepseek-ai/cordis': 4.0.2 @@ -3767,15 +3767,15 @@ snapshots: '@exodus/bytes@1.15.1': {} - '@huanlin/dsh-plugin-better-locale@0.1.0(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-client-locale@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-client-ui-primitives@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-client-ui-settings@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-client-ui-slots@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-invariants@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + '@huanlin/dsh-plugin-better-locale@0.1.0(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-client-locale@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-client-ui-primitives@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-client-ui-settings@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-client-ui-slots@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@deepseek-ai/cordis': 4.0.2 optionalDependencies: - '@deepseek-ai/dsh-client-locale': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-client-ui-primitives': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-client-ui-settings': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-client-ui-slots': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/dsh-invariants': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-client-locale': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-client-ui-primitives': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-client-ui-settings': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-client-ui-slots': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) + '@deepseek-ai/dsh-invariants': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -3841,100 +3841,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 - '@lexical/clipboard@0.49.0(typescript@5.6.2)': - dependencies: - '@lexical/extension': 0.49.0(typescript@5.6.2) - '@lexical/html': 0.49.0(typescript@5.6.2) - '@lexical/internal': 0.49.0(typescript@5.6.2) - '@lexical/list': 0.49.0(typescript@5.6.2) - '@lexical/selection': 0.49.0(typescript@5.6.2) - '@lexical/utils': 0.49.0(typescript@5.6.2) - '@types/trusted-types': 2.0.7 - lexical: 0.49.0(typescript@5.6.2) - optionalDependencies: - typescript: 5.6.2 - - '@lexical/dragon@0.49.0(typescript@5.6.2)': - dependencies: - '@lexical/extension': 0.49.0(typescript@5.6.2) - lexical: 0.49.0(typescript@5.6.2) - optionalDependencies: - typescript: 5.6.2 - - '@lexical/extension@0.49.0(typescript@5.6.2)': - dependencies: - '@lexical/internal': 0.49.0(typescript@5.6.2) - '@lexical/utils': 0.49.0(typescript@5.6.2) - '@preact/signals-core': 1.14.4 - lexical: 0.49.0(typescript@5.6.2) - optionalDependencies: - typescript: 5.6.2 - - '@lexical/history@0.49.0(typescript@5.6.2)': - dependencies: - '@lexical/extension': 0.49.0(typescript@5.6.2) - '@lexical/utils': 0.49.0(typescript@5.6.2) - lexical: 0.49.0(typescript@5.6.2) - optionalDependencies: - typescript: 5.6.2 - - '@lexical/html@0.49.0(typescript@5.6.2)': - dependencies: - '@lexical/extension': 0.49.0(typescript@5.6.2) - '@lexical/internal': 0.49.0(typescript@5.6.2) - '@lexical/selection': 0.49.0(typescript@5.6.2) - '@lexical/utils': 0.49.0(typescript@5.6.2) - lexical: 0.49.0(typescript@5.6.2) - optionalDependencies: - typescript: 5.6.2 - - '@lexical/internal@0.49.0(typescript@5.6.2)': - optionalDependencies: - typescript: 5.6.2 - - '@lexical/list@0.49.0(typescript@5.6.2)': - dependencies: - '@lexical/extension': 0.49.0(typescript@5.6.2) - '@lexical/html': 0.49.0(typescript@5.6.2) - '@lexical/internal': 0.49.0(typescript@5.6.2) - '@lexical/utils': 0.49.0(typescript@5.6.2) - lexical: 0.49.0(typescript@5.6.2) - optionalDependencies: - typescript: 5.6.2 - - '@lexical/plain-text@0.49.0(typescript@5.6.2)': - dependencies: - '@lexical/clipboard': 0.49.0(typescript@5.6.2) - '@lexical/dragon': 0.49.0(typescript@5.6.2) - '@lexical/extension': 0.49.0(typescript@5.6.2) - '@lexical/selection': 0.49.0(typescript@5.6.2) - '@lexical/utils': 0.49.0(typescript@5.6.2) - lexical: 0.49.0(typescript@5.6.2) - optionalDependencies: - typescript: 5.6.2 - - '@lexical/selection@0.49.0(typescript@5.6.2)': - dependencies: - '@lexical/internal': 0.49.0(typescript@5.6.2) - lexical: 0.49.0(typescript@5.6.2) - optionalDependencies: - typescript: 5.6.2 - - '@lexical/text@0.49.0(typescript@5.6.2)': - dependencies: - '@lexical/internal': 0.49.0(typescript@5.6.2) - lexical: 0.49.0(typescript@5.6.2) - optionalDependencies: - typescript: 5.6.2 - - '@lexical/utils@0.49.0(typescript@5.6.2)': - dependencies: - '@lexical/internal': 0.49.0(typescript@5.6.2) - '@lexical/selection': 0.49.0(typescript@5.6.2) - lexical: 0.49.0(typescript@5.6.2) - optionalDependencies: - typescript: 5.6.2 - '@lezer/common@1.5.2': {} '@lezer/cpp@1.1.6': @@ -4046,8 +3952,6 @@ snapshots: dependencies: playwright: 1.62.1 - '@preact/signals-core@1.14.4': {} - '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 @@ -4360,7 +4264,8 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 - '@types/trusted-types@2.0.7': {} + '@types/trusted-types@2.0.7': + optional: true '@types/unist@3.0.3': {} @@ -4459,7 +4364,7 @@ snapshots: '@typescript-eslint/types': 8.69.0 eslint-visitor-keys: 5.0.1 - '@ungap/structured-clone@1.3.3': {} + '@ungap/structured-clone@1.4.0': {} '@upsetjs/venn.js@2.0.0': optionalDependencies: @@ -5248,12 +5153,6 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lexical@0.49.0(typescript@5.6.2): - dependencies: - '@lexical/internal': 0.49.0(typescript@5.6.2) - optionalDependencies: - typescript: 5.6.2 - lightningcss-android-arm64@1.33.0: optional: true @@ -5345,10 +5244,10 @@ snapshots: decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.0 - micromark-util-decode-numeric-character-reference: 2.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 - micromark-util-normalize-identifier: 2.0.0 + micromark-util-normalize-identifier: 2.0.1 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 unist-util-stringify-position: 4.0.0 @@ -5368,8 +5267,8 @@ snapshots: '@types/mdast': 4.0.4 devlop: 1.1.0 mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.0.0 - micromark-util-normalize-identifier: 2.0.0 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color @@ -5377,7 +5276,7 @@ snapshots: dependencies: '@types/mdast': 4.0.4 mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.0.0 + mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -5387,7 +5286,7 @@ snapshots: devlop: 1.1.0 markdown-table: 3.0.4 mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.0.0 + mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -5396,7 +5295,7 @@ snapshots: '@types/mdast': 4.0.4 devlop: 1.1.0 mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.0.0 + mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -5408,7 +5307,7 @@ snapshots: mdast-util-gfm-strikethrough: 2.0.0 mdast-util-gfm-table: 2.0.0 mdast-util-gfm-task-list-item: 2.0.0 - mdast-util-to-markdown: 2.0.0 + mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -5419,7 +5318,7 @@ snapshots: devlop: 1.1.0 longest-streak: 3.1.0 mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.0 + mdast-util-to-markdown: 2.1.2 unist-util-remove-position: 5.0.0 transitivePeerDependencies: - supports-color @@ -5433,7 +5332,7 @@ snapshots: dependencies: '@types/hast': 3.0.5 '@types/mdast': 4.0.4 - '@ungap/structured-clone': 1.3.3 + '@ungap/structured-clone': 1.4.0 devlop: 1.1.0 micromark-util-sanitize-uri: 2.0.1 trim-lines: 3.0.1 @@ -5441,24 +5340,14 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 - mdast-util-to-markdown@2.0.0: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - longest-streak: 3.1.0 - mdast-util-phrasing: 4.1.0 - mdast-util-to-string: 4.0.0 - micromark-util-decode-string: 2.0.1 - unist-util-visit: 5.1.0 - zwitch: 2.0.4 - - mdast-util-to-markdown@2.1.0: + mdast-util-to-markdown@2.1.2: dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 longest-streak: 3.1.0 mdast-util-phrasing: 4.1.0 mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 micromark-util-decode-string: 2.0.1 unist-util-visit: 5.1.0 zwitch: 2.0.4 @@ -5498,18 +5387,18 @@ snapshots: dependencies: decode-named-character-reference: 1.3.0 devlop: 1.1.0 - micromark-factory-destination: 2.0.0 - micromark-factory-label: 2.0.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 micromark-factory-space: 2.0.1 - micromark-factory-title: 2.0.0 - micromark-factory-whitespace: 2.0.0 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 micromark-util-character: 2.1.1 - micromark-util-chunked: 2.0.0 + micromark-util-chunked: 2.0.1 micromark-util-classify-character: 2.0.1 - micromark-util-html-tag-name: 2.0.0 - micromark-util-normalize-identifier: 2.0.0 - micromark-util-resolve-all: 2.0.0 - micromark-util-subtokenize: 2.0.0 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 @@ -5526,7 +5415,7 @@ snapshots: micromark-core-commonmark: 2.0.3 micromark-factory-space: 2.0.1 micromark-util-character: 2.1.1 - micromark-util-normalize-identifier: 2.0.0 + micromark-util-normalize-identifier: 2.0.1 micromark-util-sanitize-uri: 2.0.1 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 @@ -5534,9 +5423,9 @@ snapshots: micromark-extension-gfm-strikethrough@2.1.0: dependencies: devlop: 1.1.0 - micromark-util-chunked: 2.0.0 + micromark-util-chunked: 2.0.1 micromark-util-classify-character: 2.0.1 - micromark-util-resolve-all: 2.0.0 + micromark-util-resolve-all: 2.0.1 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 @@ -5568,7 +5457,7 @@ snapshots: micromark-extension-gfm-table: 2.1.1 micromark-extension-gfm-tagfilter: 2.0.0 micromark-extension-gfm-task-list-item: 2.1.0 - micromark-util-combine-extensions: 2.0.0 + micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 micromark-extension-math@3.1.0: @@ -5581,13 +5470,13 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - micromark-factory-destination@2.0.0: + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - micromark-factory-label@2.0.0: + micromark-factory-label@2.0.1: dependencies: devlop: 1.1.0 micromark-util-character: 2.1.1 @@ -5599,14 +5488,14 @@ snapshots: micromark-util-character: 2.1.1 micromark-util-types: 2.0.2 - micromark-factory-title@2.0.0: + micromark-factory-title@2.0.1: dependencies: micromark-factory-space: 2.0.1 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - micromark-factory-whitespace@2.0.0: + micromark-factory-whitespace@2.0.1: dependencies: micromark-factory-space: 2.0.1 micromark-util-character: 2.1.1 @@ -5618,7 +5507,7 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - micromark-util-chunked@2.0.0: + micromark-util-chunked@2.0.1: dependencies: micromark-util-symbol: 2.0.1 @@ -5628,12 +5517,12 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - micromark-util-combine-extensions@2.0.0: + micromark-util-combine-extensions@2.0.1: dependencies: - micromark-util-chunked: 2.0.0 + micromark-util-chunked: 2.0.1 micromark-util-types: 2.0.2 - micromark-util-decode-numeric-character-reference@2.0.0: + micromark-util-decode-numeric-character-reference@2.0.2: dependencies: micromark-util-symbol: 2.0.1 @@ -5641,31 +5530,31 @@ snapshots: dependencies: decode-named-character-reference: 1.3.0 micromark-util-character: 2.1.1 - micromark-util-decode-numeric-character-reference: 2.0.0 + micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-symbol: 2.0.1 - micromark-util-encode@2.0.0: {} + micromark-util-encode@2.0.1: {} - micromark-util-html-tag-name@2.0.0: {} + micromark-util-html-tag-name@2.0.1: {} - micromark-util-normalize-identifier@2.0.0: + micromark-util-normalize-identifier@2.0.1: dependencies: micromark-util-symbol: 2.0.1 - micromark-util-resolve-all@2.0.0: + micromark-util-resolve-all@2.0.1: dependencies: micromark-util-types: 2.0.2 micromark-util-sanitize-uri@2.0.1: dependencies: micromark-util-character: 2.1.1 - micromark-util-encode: 2.0.0 + micromark-util-encode: 2.0.1 micromark-util-symbol: 2.0.1 - micromark-util-subtokenize@2.0.0: + micromark-util-subtokenize@2.1.0: dependencies: devlop: 1.1.0 - micromark-util-chunked: 2.0.0 + micromark-util-chunked: 2.0.1 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 @@ -5673,7 +5562,7 @@ snapshots: micromark-util-types@2.0.2: {} - micromark@4.0.0: + micromark@4.0.2: dependencies: '@types/debug': 4.1.13 debug: 4.4.3 @@ -5682,14 +5571,14 @@ snapshots: micromark-core-commonmark: 2.0.3 micromark-factory-space: 2.0.1 micromark-util-character: 2.1.1 - micromark-util-chunked: 2.0.0 - micromark-util-combine-extensions: 2.0.0 - micromark-util-decode-numeric-character-reference: 2.0.0 - micromark-util-encode: 2.0.0 - micromark-util-normalize-identifier: 2.0.0 - micromark-util-resolve-all: 2.0.0 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 micromark-util-sanitize-uri: 2.0.1 - micromark-util-subtokenize: 2.0.0 + micromark-util-subtokenize: 2.1.0 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 transitivePeerDependencies: diff --git a/src/assistant-live.ts b/src/assistant-live.ts new file mode 100644 index 0000000..e2a25e2 --- /dev/null +++ b/src/assistant-live.ts @@ -0,0 +1,140 @@ +/** + * Process-local live assistant stream buffer (DSH 0.1.5+). + * + * 0.1.2 appended a durable `assistant/chunk` session event for every model + * delta, so the side-chat transcript and the inherited in-progress snapshot + * could read streaming text straight out of the session log. 0.1.5 removed + * that event: an in-flight attempt now publishes `agent/assistant-stream` + * frames (start / chunk / end) that are NOT part of the log, and the durable + * record lands only at settlement — `assistant/message` (with the exact + * `stream` embedded) or `assistant/attempt` (a failed attempt that committed + * no message, also with its `stream`). + * + * This module folds those frames into a bounded per-session buffer of + * normalized chunks — the same information the old `assistant/chunk` events + * carried — so the plugin keeps a live transcript and an honest in-progress + * snapshot. The buffer is cleared when an attempt ends, so a settled step + * never duplicates its durable message. + */ +import type { Context } from './context-types.ts' + +/** One normalized live delta, keyed by its attempt and dense position. */ +export interface AssistantLiveChunk { + /** The attempt these chunks belong to (DSH `LlmAttemptId`). */ + readonly attemptId: string + /** The turn the attempt belongs to. */ + readonly turn: number + /** The step the attempt belongs to. */ + readonly step: number + /** Dense zero-based position within the attempt (DSH's own index). */ + readonly index: number + /** Safe-integer timestamp of the frame (reused by the durable stream). */ + readonly time: number + /** The raw model stream chunk (`text-delta` / `reasoning-delta` / …). */ + readonly chunk: Record +} + +/** The plugin's read face over the live frames of every session. */ +export interface AssistantLiveBuffer { + /** + * The live chunks of one session's active attempt, in index order. + * @param sessionId - the session whose attempt is streaming. + * @returns the chunks; empty when nothing is streaming for that session. + */ + chunksFor(sessionId: string): readonly AssistantLiveChunk[] + /** Stop observing the agent stream. */ + dispose(): void +} + +/** Per-attempt buffer ceiling; beyond it the oldest deltas are dropped. */ +export const LIVE_CHUNK_CAP = 4000 + +/** How many sessions may hold a live buffer at once (the oldest is dropped). */ +export const LIVE_SESSION_CAP = 64 + +/** One session's active attempt as it accumulates. */ +interface Attempt { + readonly attemptId: string + readonly turn: number + readonly step: number + readonly chunks: AssistantLiveChunk[] + /** The next dense frame index this attempt accepts (independent of the + * window below, which drops the oldest chunks once the cap is reached). */ + nextIndex: number +} + +/** A frame's `chunk` payload must be a JSON record to be worth buffering. */ +function chunkOf(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : undefined +} + +/** A finite non-negative integer, or undefined. */ +function countOf(value: unknown): number | undefined { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined +} + +/** + * Fold `agent/assistant-stream` frames into per-session live buffers. + * + * Frames arrive for every attached agent, so the buffer is keyed by the + * emitting session and ignores anything it cannot identify. An out-of-order + * or mismatched chunk drops the attempt rather than splicing a gap: a + * transcript with a hole is worse than one that settles at the next durable + * event. + * @param ctx - host plugin context (its `on` subscribes the session feed). + * @param cap - per-attempt chunk ceiling. + * @returns the read face and a disposer unbinding the listener. + */ +export function createAssistantLiveBuffer(ctx: Context, cap: number = LIVE_CHUNK_CAP): AssistantLiveBuffer { + const attempts = new Map() + const off = ctx.on('agent/assistant-stream', (payload) => { + const record = payload as { agent?: unknown; frame?: unknown } | null + const frame = record?.frame as Record | undefined + if (frame === undefined) return + const session = (record?.agent as { session?: { id?: unknown } } | undefined)?.session + const sessionId = typeof session?.id === 'string' ? session.id : undefined + if (sessionId === undefined) return + const type = frame['type'] + if (type === 'end') { + attempts.delete(sessionId) + return + } + const attemptId = typeof frame['attemptId'] === 'string' ? frame.attemptId : undefined + if (attemptId === undefined) return + if (type === 'start') { + const turn = countOf(frame['turn']) + const step = countOf(frame['step']) + if (turn === undefined || step === undefined) return + const known = attempts.has(sessionId) + attempts.delete(sessionId) + // Only a NEW session may evict the oldest tracked one. + if (!known && attempts.size >= LIVE_SESSION_CAP) { + const oldest = attempts.keys().next().value + if (oldest !== undefined) attempts.delete(oldest) + } + attempts.set(sessionId, { attemptId, turn, step, chunks: [], nextIndex: 0 }) + return + } + if (type !== 'chunk') return + const attempt = attempts.get(sessionId) + if (attempt === undefined || attempt.attemptId !== attemptId) return + const index = countOf(frame['index']) + const chunk = chunkOf(frame['chunk']) + // Dense positions only: a gap means the buffer missed frames (a + // replacement attempt, a reconnect) and its text would be wrong. + if (index === undefined || chunk === undefined || index !== attempt.nextIndex) { + attempts.delete(sessionId) + return + } + attempt.nextIndex = index + 1 + const time = countOf(frame['time']) ?? 0 + attempt.chunks.push({ attemptId, turn: attempt.turn, step: attempt.step, index, time, chunk }) + if (attempt.chunks.length > cap) attempt.chunks.splice(0, attempt.chunks.length - cap) + }) + return { + chunksFor: sessionId => attempts.get(sessionId)?.chunks ?? [], + dispose: () => { off() }, + } +} diff --git a/src/client/EditorHost.tsx b/src/client/EditorHost.tsx index d09b281..e5421d9 100644 --- a/src/client/EditorHost.tsx +++ b/src/client/EditorHost.tsx @@ -42,7 +42,7 @@ import { relativeTo } from './paths.ts' import { resolveSidebarPath } from './produced-files.ts' import { closePathTabs, retargetPathTabs } from './tree-mutations.ts' import type { EditorToolbarControls, EditorToolbarState, FileViewerDescriptor } from './service.ts' -import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, treeOf, type SidebarStore, type SidebarTab } from './state.ts' +import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, type SidebarStore, type SidebarTab } from './state.ts' import css from './sidebar.module.css' type EditorLoad = @@ -178,8 +178,7 @@ export function EditorHost(props: { */ const openFileSide = (absolute: string): void => { store.reduce((state) => { - const key = treeOf(state, tab.id) - const pane = leafWithTab(state[key], tab.id) ?? firstLeaf(state[key]) + const pane = leafWithTab(state.bottomSplits, tab.id) ?? firstLeaf(state.bottomSplits) const fresh: SidebarTab = { id: mintTabId(), type: 'editor', @@ -187,8 +186,8 @@ export function EditorHost(props: { path: absolute, meta: { treeOpen: false }, } - const { node, leafId } = insertLeafAt(state[key], pane.id, 'row', fresh, false) - return { ...state, [key]: node, activePane: leafId } + const { node, leafId } = insertLeafAt(state.bottomSplits, pane.id, 'row', fresh, false) + return { ...state, bottomSplits: node, activePane: leafId } }) } diff --git a/src/client/FreeWindow.tsx b/src/client/FreeWindow.tsx deleted file mode 100644 index 6ded906..0000000 --- a/src/client/FreeWindow.tsx +++ /dev/null @@ -1,338 +0,0 @@ -/** - * One free window: a tab dragged out of the workbench floating over the - * conversation area at viewport coordinates (rendered inside the panel host, - * so desktop-shell transforms can never hijack its fixed containing block). - * - * The header drags the window with the panel-resize pattern — pointer - * capture + per-frame direct DOM writes + a store commit on release — and - * doubling as the DOCK-BACK gesture: while the pointer is over a workbench - * pane ([data-dsh-pane], either panel), that pane highlights live and - * releasing docks the tab into it (center merge); releasing anywhere else - * just moves the window. The SE corner resizes, any press raises (the - * floats array's order is the stacking order), the header right-click menu - * and the X button dock / close. The tab content reuses the regular tab - * renderer, so every tab type (terminal, editor, plugin tabs) floats - * unchanged. - */ -import { useEffect, useRef, useState, type ReactNode } from 'react' -import clsx from 'clsx' -import { IconCloseFill14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' -import type { FloatWindow, SidebarTab } from './state.ts' -import { FLOAT_MIN_H, FLOAT_MIN_W } from './state.ts' -import { t } from './locales.ts' -import css from './sidebar.module.css' - -/** A drag's mutable bookkeeping (start geometry + the last applied values). */ -interface DragState { - /** Which handle owns the drag (the abort path reads it from this ref — - * the React `dragging` state lags a render behind pointerdown). */ - mode: 'move' | 'resize' - pointerX: number - pointerY: number - startX: number - startY: number - startW: number - startH: number - /** Last geometry actually written to the DOM (the cancel-path fallback). */ - applied: { x: number; y: number; w: number; h: number } - /** Settled after the final pointer event (the up position wins over the - * rAF-pending value — a fast flick coalesces its tail into the up event). */ - committed: boolean -} - -/** The pane under a viewport point, if any (rect hit-test; the dragged - * window itself is not a pane, so it cannot shadow the targets). */ -function paneAt(x: number, y: number): HTMLElement | null { - for (const pane of document.querySelectorAll('[data-dsh-pane]')) { - const rect = pane.getBoundingClientRect() - if (rect.width === 0 || rect.height === 0) continue - if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return pane - } - return null -} - -/** Pointer-capture helpers tolerant of environments without the API (jsdom - * lacks setPointerCapture — component tests dispatch plain MouseEvents, so - * the optional calls keep them driving the drag; real browsers always have - * it and a missing pointerId can never occur there). */ -const capturePointer = (element: HTMLElement, pointerId: number): void => { - element.setPointerCapture?.(pointerId) -} - -const releasePointer = (element: HTMLElement, pointerId: number): void => { - element.releasePointerCapture?.(pointerId) -} - -/** Whether the element holds the pointer (assumed true without the API). */ -const holdsPointer = (element: HTMLElement, pointerId: number): boolean => { - return element.hasPointerCapture?.(pointerId) !== false -} - -export function FreeWindow(props: { - float: FloatWindow - renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode - getTabIcon?: (tab: SidebarTab) => ReactNode - onRaise: () => void - onMove: (x: number, y: number) => void - onResize: (w: number, h: number) => void - /** Dock the tab back; the pane id, or null for the active pane. */ - onDock: (paneId: string | null) => void - onClose: () => void -}) { - const { float, renderTab, getTabIcon, onRaise, onMove, onResize, onDock, onClose } = props - const rootRef = useRef(null) - const dragRef = useRef(null) - // The pane currently highlighted as the dock target (a direct DOM write: - // per-frame React state would re-render every mounted tab at drag cadence). - const dockTargetRef = useRef(null) - // rAF coalescing: pointermove outruns the display, one write per frame. - const frameRef = useRef(null) - const pendingRef = useRef<{ x: number; y: number; w: number; h: number } | null>(null) - const [dragging, setDragging] = useState<'move' | 'resize' | null>(null) - const [menu, setMenu] = useState<{ x: number; y: number } | null>(null) - - useEffect(() => () => { - if (frameRef.current !== null) cancelAnimationFrame(frameRef.current) - dockTargetRef.current?.removeAttribute('data-dsh-float-dock-over') - }, []) - - /** Apply the pending geometry to the DOM (one rAF per frame at most). */ - const scheduleApply = (geo: { x: number; y: number; w: number; h: number }): void => { - pendingRef.current = geo - if (frameRef.current !== null) return - frameRef.current = requestAnimationFrame(() => { - frameRef.current = null - const pending = pendingRef.current - const drag = dragRef.current - const root = rootRef.current - if (pending === null || drag === null || root === null) return - drag.applied = pending - root.style.left = `${pending.x}px` - root.style.top = `${pending.y}px` - root.style.width = `${pending.w}px` - root.style.height = `${pending.h}px` - }) - } - - /** Flush the pending frame synchronously (the release path's last write). */ - const flushNow = (): void => { - const pending = pendingRef.current - const drag = dragRef.current - const root = rootRef.current - if (pending === null || drag === null || root === null) return - if (frameRef.current !== null) { - cancelAnimationFrame(frameRef.current) - frameRef.current = null - } - pendingRef.current = null - drag.applied = pending - root.style.left = `${pending.x}px` - root.style.top = `${pending.y}px` - root.style.width = `${pending.w}px` - root.style.height = `${pending.h}px` - } - - const clearDockHighlight = (): void => { - dockTargetRef.current?.removeAttribute('data-dsh-float-dock-over') - dockTargetRef.current = null - } - - /** Release a drag: `pane` (when set) docks instead of moving. */ - const finishDrag = (mode: 'move' | 'resize', geo: { x: number; y: number; w: number; h: number }, pane: HTMLElement | null): void => { - const drag = dragRef.current - if (drag === null || drag.committed) return - drag.committed = true - dragRef.current = null - setDragging(null) - const target = pane ?? dockTargetRef.current - clearDockHighlight() - if (mode === 'move' && target !== null) { - onDock(target.getAttribute('data-dsh-pane')) - } else if (mode === 'move') { - onMove(geo.x, geo.y) - } else { - onResize(geo.w, geo.h) - } - } - - /** Cancel-path settle (pointercancel / lostpointercapture): the last - * APPLIED geometry is the user-visible truth — commit it, never roll back - * (the panel drags' issue-#247 semantics). The mode comes from the ref — - * the React `dragging` state can still be null when the cancel lands - * before the pointerdown re-render commits. */ - const abortDrag = (): void => { - const drag = dragRef.current - if (drag === null || drag.committed) return - flushNow() - finishDrag(drag.mode, drag.applied, null) - } - - const clampMove = (x: number, y: number): { x: number; y: number; w: number; h: number } => { - const vw = window.innerWidth - const vh = window.innerHeight - return { - x: Math.min(Math.max(x, 0), Math.max(0, vw - float.w)), - y: Math.min(Math.max(y, 0), Math.max(0, vh - float.h)), - w: float.w, - h: float.h, - } - } - - const clampResize = (w: number, h: number): { x: number; y: number; w: number; h: number } => { - const vw = window.innerWidth - const vh = window.innerHeight - return { - x: float.x, - y: float.y, - w: Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw - float.x))), - h: Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh - float.y))), - } - } - - return ( -
{ onRaise() }} - > -
{ - if (event.button !== 0) return - // Portaled overlays (the header Menu) live at document.body in the - // DOM, yet their events still bubble through the React tree into - // this handler — without the containment guard a menu-row click - // would start a drag, preventDefault (killing the row's compat - // mousedown) and capture the pointer (retargeting the release at - // the header, so the row's click never fires). Buttons inside the - // header (the close X) need the same exclusion: a capture here - // swallows their click. - if (!(event.target instanceof Node) || !event.currentTarget.contains(event.target)) return - if (event.target instanceof Element && event.target.closest('button') !== null) return - event.preventDefault() - capturePointer(event.currentTarget, event.pointerId) - dragRef.current = { - mode: 'move', - pointerX: event.clientX, - pointerY: event.clientY, - startX: float.x, - startY: float.y, - startW: float.w, - startH: float.h, - applied: { x: float.x, y: float.y, w: float.w, h: float.h }, - committed: false, - } - setDragging('move') - }} - onPointerMove={(event) => { - const drag = dragRef.current - if (drag === null || !holdsPointer(event.currentTarget, event.pointerId)) return - const geo = clampMove(drag.startX + (event.clientX - drag.pointerX), drag.startY + (event.clientY - drag.pointerY)) - scheduleApply(geo) - const target = paneAt(event.clientX, event.clientY) - if (target !== dockTargetRef.current) { - clearDockHighlight() - if (target !== null) target.setAttribute('data-dsh-float-dock-over', '') - dockTargetRef.current = target - } - }} - onPointerUp={(event) => { - if (dragRef.current === null || !holdsPointer(event.currentTarget, event.pointerId)) return - releasePointer(event.currentTarget, event.pointerId) - flushNow() - const geo = clampMove( - dragRef.current.startX + (event.clientX - dragRef.current.pointerX), - dragRef.current.startY + (event.clientY - dragRef.current.pointerY), - ) - finishDrag('move', geo, paneAt(event.clientX, event.clientY)) - }} - onPointerCancel={() => { abortDrag() }} - onLostPointerCapture={() => { abortDrag() }} - onContextMenu={(event) => { - event.preventDefault() - setMenu({ x: event.clientX, y: event.clientY }) - }} - > - {getTabIcon?.(float.tab) ?? null} - {float.tab.title} - - { setMenu(null) }} - items={[ - { id: 'dock', label: t('dockToSidebar') }, - { id: 'close', label: t('close') }, - ]} - onSelect={(id) => { - setMenu(null) - if (id === 'dock') onDock(null) - else if (id === 'close') onClose() - }} - portal - compact - align="start" - getAnchorRect={() => (menu === null ? null : new DOMRect(menu.x, menu.y, 0, 0))} - anchor={} - /> -
- {/* The tab cell reuses .paneTab (the pane's per-tab wrapper): the - column-flex chain (window → .floatContent → .paneTab) is what - gives the component a definite width via align-stretch — the exact - sizing contract a tab gets inside a pane. */} -
-
{renderTab(float.tab, true, float.id)}
-
-
{ - if (event.button !== 0) return - if (!(event.target instanceof Node) || !event.currentTarget.contains(event.target)) return - event.preventDefault() - capturePointer(event.currentTarget, event.pointerId) - dragRef.current = { - mode: 'resize', - pointerX: event.clientX, - pointerY: event.clientY, - startX: float.x, - startY: float.y, - startW: float.w, - startH: float.h, - applied: { x: float.x, y: float.y, w: float.w, h: float.h }, - committed: false, - } - setDragging('resize') - }} - onPointerMove={(event) => { - const drag = dragRef.current - if (drag === null || !holdsPointer(event.currentTarget, event.pointerId)) return - scheduleApply(clampResize( - drag.startW + (event.clientX - drag.pointerX), - drag.startH + (event.clientY - drag.pointerY), - )) - }} - onPointerUp={(event) => { - if (dragRef.current === null || !holdsPointer(event.currentTarget, event.pointerId)) return - releasePointer(event.currentTarget, event.pointerId) - flushNow() - const geo = clampResize( - dragRef.current.startW + (event.clientX - dragRef.current.pointerX), - dragRef.current.startH + (event.clientY - dragRef.current.pointerY), - ) - finishDrag('resize', geo, null) - }} - onPointerCancel={() => { abortDrag() }} - onLostPointerCapture={() => { abortDrag() }} - /> -
- ) -} diff --git a/src/client/RenderBoundary.tsx b/src/client/RenderBoundary.tsx index f856adf..32e66b2 100644 --- a/src/client/RenderBoundary.tsx +++ b/src/client/RenderBoundary.tsx @@ -7,7 +7,7 @@ * errors in the sidebar shell itself (Workbench, drag layout, …) — a full * swap keeps the page alive. * - PER-TAB (Sidebar.tsx TabContent, `css.tabBoundaryError`): a crashing - * viewer/editor shows a strip inside ITS OWN pane; the toggle cluster, the + * viewer/editor shows a strip inside ITS OWN pane; the workbench shell, the * other tabs, and the panel itself stay alive (issue #31 — a tab crash * must never take down the whole sidebar). * diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx index 6a36a7b..14c865f 100644 --- a/src/client/SideCardSection.tsx +++ b/src/client/SideCardSection.tsx @@ -54,11 +54,8 @@ import clsx from 'clsx' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { - clampWidthPercent, TITLE_BAR_STRIP_MAX, TITLE_BAR_STRIP_MIN, - WIDTH_PERCENT_MAX, - WIDTH_PERCENT_MIN, type SidebarPrefs, } from '../prefs-shared.ts' import { api } from './api.ts' @@ -572,7 +569,6 @@ export function SettingsBody(props: { */ export function SideCardSection({ store, service }: SideCardSectionProps) { const [prefs, setPrefs] = useState(() => store.getPrefs()) - const [widthDraft, setWidthDraft] = useState(String(store.getPrefs().defaultWidthPercent)) const [error, setError] = useState(null) // Which feature's secondary settings popup is open (null = closed). const [settingsFor, setSettingsFor] = useState(null) @@ -624,9 +620,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { if (cancelled) return revisionRef.current = view.revision if (dirtyRef.current) return - const next = parsePrefs(view.value) - setPrefs(next) - setWidthDraft(String(next.defaultWidthPercent)) + setPrefs(parsePrefs(view.value)) }).catch(() => { /* the store's defaults stay authoritative */ }) return () => { cancelled = true } }, []) @@ -657,9 +651,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { /** Settle one commit: success adopts the server values, failure reverts. */ const applyOutcome = (previous: SidebarPrefs, outcome: { ok: boolean; prefs: SidebarPrefs }): void => { - const settled = outcome.ok ? outcome.prefs : previous - setPrefs(settled) - setWidthDraft(String(settled.defaultWidthPercent)) + setPrefs(outcome.ok ? outcome.prefs : previous) } /** Optimistically apply one pref patch, then commit (revert on failure). */ @@ -672,10 +664,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { void commit(patch).then(outcome => applyOutcome(previous, outcome)) } - const onToggle = (next: boolean): void => { - applyPref({ openByDefault: next }) - } - /** Flip one per-tab enable switch (merge into the tabsEnabled map). */ const onToggleTab = (id: string, next: boolean): void => { applyPref({ tabsEnabled: { ...optimisticRef.current.tabsEnabled, [id]: next } }) @@ -778,20 +766,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { return raw } - const commitWidth = (): void => { - const parsed = Number(widthDraft) - if (!Number.isFinite(parsed)) { - setWidthDraft(String(prefs.defaultWidthPercent)) - return - } - const clamped = clampWidthPercent(parsed) - const previous = prefs - setPrefs({ ...previous, defaultWidthPercent: clamped }) - setWidthDraft(String(clamped)) - setError(null) - void commit({ defaultWidthPercent: clamped }).then(outcome => applyOutcome(previous, outcome)) - } - /** * One SMALL toggle card for the responsive inventory grid: the card's main * area is the switch (click to flips, visual state IS the state), the icon @@ -867,51 +841,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { {/* 常规: the DSH settings-row recipe — title/desc left, control right. */}
{t('settingsGeneralTitle')}
-
- - {t('settingsOpenTitle')} - {t('settingsOpenDesc')} - - -
-
- - {t('settingsWidthTitle')} - {t('settingsWidthDesc')} - - - { setWidthDraft(event.currentTarget.value) }} - onBlur={commitWidth} - onKeyDown={event => { - if (event.key === 'Enter') event.currentTarget.blur() - }} - /> - {t('settingsWidthSuffix')} - -
-
- - {t('settingsOpenPathTitle')} - {t('settingsOpenPathDesc')} - - { applyPref({ interceptOpenPath: next }) }} - /> -
{t('settingsOpenToolsTitle')} diff --git a/src/client/SideChatView.tsx b/src/client/SideChatView.tsx index 367ee88..ed78619 100644 --- a/src/client/SideChatView.tsx +++ b/src/client/SideChatView.tsx @@ -58,6 +58,7 @@ import { sideThreadRows, threadHasCompletedTurn, threadTrailingPending, + type SidechatLiveEvent, type SidechatThreadInfo, } from '../sidechat-core.ts' import { @@ -112,6 +113,8 @@ const inFlightStarts = new Set() * the afterSeq delta and never re-download what they already hold). */ interface ThreadCache { entries: SidebarHistoryEntry[] + /** The CURRENT attempt's live deltas (replaced every pull, never merged). */ + live: SidechatLiveEvent[] } /** Row-render labels (locale-dependent, memoized once per mount). */ @@ -383,7 +386,7 @@ export function SideChatView(props: { const [info, setInfo] = useState(null) const [menuOpen, setMenuOpen] = useState(false) - const cacheRef = useRef({ entries: [] }) + const cacheRef = useRef({ entries: [], live: [] }) // The previous poll's rows (see the mapping's reuse pass below). const prevRowsRef = useRef([]) const controllerRef = useRef(null) @@ -457,14 +460,18 @@ export function SideChatView(props: { try { const cache = cacheRef.current const afterSeq = cache.entries.at(-1)?.event.seq - const { events } = await api.sidechatEvents(childId, afterSeq, controller.signal) + const { events, live } = await api.sidechatEvents(childId, afterSeq, controller.signal) + // The live set is the CURRENT attempt, so it replaces whatever the + // previous pull held; a settled step drops out of the host buffer and + // its durable assistant/message takes over in the mapping. + cache.live = live ?? [] if (events.length > 0) { // Wire events arrive as parsed JSON; the mirror narrows data to the // record the mapping reads. const incoming = events.map(event => ({ event: event as SidebarSessionEvent })) cache.entries = mergeBySeq(cache.entries, incoming) - setRevision(value => value + 1) } + setRevision(value => value + 1) } catch { // Aborted by a newer pull or a wire failure: keep the last rows. } @@ -482,7 +489,7 @@ export function SideChatView(props: { // Reset the transcript cache whenever the binding changes, then focus // the composer — it owns the first message of a fresh thread. useEffect(() => { - cacheRef.current = { entries: [] } + cacheRef.current = { entries: [], live: [] } prevRowsRef.current = [] controllerRef.current?.abort() setError(null) @@ -532,8 +539,24 @@ export function SideChatView(props: { // The previous poll's rows ride into the mapping so unchanged rows keep // their object identity (see reuseRows): the 2s poll re-renders only the // changed tail instead of re-parsing markdown for the whole transcript. + // The live deltas are appended AFTER the durable events (they are the + // in-flight tail), and a delta whose step already settled durably is + // dropped: the durable message is authoritative and would otherwise render + // twice. const rows = useMemo(() => { - const next = threadId === undefined ? [] : transcriptRows(cacheRef.current.entries, prevRowsRef.current) + const cache = cacheRef.current + const settled = new Set( + cache.entries + .filter(entry => entry.event.type === 'assistant/message') + .map(entry => { + const data = entry.event.data as { turn?: unknown; step?: unknown } + return `${String(data.turn)}:${String(data.step)}` + }), + ) + const live = cache.live + .filter(event => !settled.has(`${String(event.data.turn)}:${String(event.data.step)}`)) + .map(event => ({ event: event as SidebarSessionEvent })) + const next = threadId === undefined ? [] : transcriptRows([...cache.entries, ...live], prevRowsRef.current) prevRowsRef.current = next return next }, diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index cb982be..f73f4dd 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -1,51 +1,46 @@ /** - * The sidebar shell: panels mounted inside the unified panel host — a - * fixed, viewport-sized containing block ([data-dsh-panel-host]) appended - * to document.body — instead of individual fixed-position elements, so a - * desktop shell's intermediate wrapper transforms can never hijack the - * panels' fixed containing block (the core AppFrame owns the left sidebar / - * center / details columns and has no right-side hole for plugins). The - * right panel hosts the original workbench; the bottom panel hosts a - * second, independent workbench. The bottom panel squeezes ONLY the center - * column (the agent output area): it spans from the app shell's own left - * sidebar to the right panel's left edge, so neither sidebar gives up any - * position (the right panel keeps its full height). A persistent two-button - * cluster at the top-right corner toggles each panel; the right panel's - * width drags from its left edge, the bottom panel's height from its top - * edge, and the shared corner drags both at once. The whole layout lives in - * the per-session store, so switching conversations swaps the sidebar. + * The sidebar shell: ONE surface — the bottom workbench docked to the + * conversation column. + * + * DSH 0.1.5 owns the right column: its native Sidebar hosts every tab type + * this plugin registers (client/native/), so this shell renders no right + * panel of its own. What remains plugin-owned is the bottom workbench: it + * spans ONLY the AppFrame's center column (the agent output area), from the + * app shell's own left sidebar to the details column's left edge, so neither + * sidebar gives up any position. Its height drags from its top edge, and the + * expand/collapse control is a header button registered into DSH's + * `conversation.session.header.utilities` list slot + * (sidebar/bottom-toggle.tsx) — the header's right corner belongs to the + * native sidebar's own expand control. + * + * The panel is mounted inside the unified panel host — a fixed, + * viewport-sized containing block ([data-dsh-panel-host]) appended to + * document.body — instead of a fixed-position element, so a desktop shell's + * intermediate wrapper transforms can never hijack its containing block. + * The whole layout lives in the per-session store, so switching + * conversations swaps the workbench. * * The shell binds the workbench actions to the store and dispatches tab * content to the views. New tabs come from the + menu (explorer / git / - * terminal; editors open from the explorer). Tabs live in one tree only — - * they never cross panels; only the panel sizes drag against each other. - * - * Narrow (mobile, <768px) viewports show ONLY the right sidebar: entering - * narrow migrates the bottom panel's tabs INTO the right tree - * (migrateBottomTabs) — one workbench, the bottom tabs thrown into its - * strips. The right panel becomes a full-width drawer, the bottom panel - * and its toggle button disappear, and the layout push is disabled (the - * drawer floats). Widening does not migrate back: the tabs keep living in - * the right tree. + * terminal; editors open from the explorer). */ 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 type { Context } from '../context-types.ts' -import { appendToDraft, insertFileReference } from './conversation-draft.ts' +import { referenceInChat as referenceInChatShared } from './reference-in-chat.ts' import { - BOTTOM_MIN, PANEL_MIN, agentUuidOf, firstLeaf, floatTab, - isAgentTabId, leafWithTab, migrateBottomTabs, - moveTab, moveTabToEdge, openDiffTab, resizeSplitIn, - setBottomHeight, setTabPin, setWidth, toggleBottomPanel, toggleExpanded, togglePanel, - type DropZone, type SidebarState, type SidebarStore, type SidebarTab, + BOTTOM_MIN, CONVERSATION_MIN, agentUuidOf, firstLeaf, isAgentTabId, + leafWithTab, moveTab, moveTabToEdge, openDiffTab, resizeSplitIn, + setBottomHeight, setTabPin, toggleBottomPanel, toggleExpanded, + type DropZone, type SidebarStore, type SidebarTab, } from './state.ts' import { getPinnedHomeScope } from './pinned.ts' -import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx' +import { IconPanelBottomOutline16 } from './icons.tsx' import { Workbench, type WorkbenchActions } from './split-pane.tsx' -import { isNarrowWidth, useViewportSize } from './breakpoints.ts' -import { layoutPushSize } from './layout-push.ts' +import { useViewportSize } from './breakpoints.ts' +import { bottomPushHeight } from './layout-push.ts' import { parseDesktopEnv } from './desktop-env.ts' import { getWcoSnapshot, subscribeWco } from './wco.ts' import { getShellPreset } from './shell-presets.ts' @@ -54,9 +49,7 @@ import { TabContent, buildNewTabOptions } from './sidebar/TabContent.tsx' import { useCenterColumn } from './sidebar/use-center-column.ts' import { useHostFeeds } from './sidebar/use-host-feeds.ts' import { usePinnedTabs } from './sidebar/use-pinned-tabs.ts' -import { FreeWindowLayer, useFloatDragout } from './sidebar/free-windows.tsx' import type { TabDragPayload } from './TabBar.tsx' -import { relativeTo } from './paths.ts' import { t } from './locales.ts' import { api } from './api.ts' import css from './sidebar.module.css' @@ -65,7 +58,7 @@ import css from './sidebar.module.css' * OS file drags over the sidebar belong to the sidebar, not to the chat: * DSH's composer (InputBar) listens for file drags on the DOCUMENT and * answers with a full-screen "drop image here" mask plus image intake on - * drop. Both panel-host render sites swallow the whole event quartet — + * drop. The panel host swallows the whole event quartet — * enter/over/leave/drop — so the region is a black hole to that document * listener. All four must be stopped: InputBar keeps an enter/leave depth * counter, and a leave that escapes without its matching enter unbalances @@ -160,16 +153,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { return service.subscribe(() => setTabsVersion(version => version + 1)) }, [ctx]) - // Narrow (mobile) viewports collapse the two panels into one: the right - // panel becomes a full-width drawer holding BOTH workbenches, the bottom - // panel (and its toggle button) disappears, and the layout push is - // disabled (the drawer floats over the app shell). Entering narrow - // MIGRATES the bottom tree's tabs into the right tree (migrateBottomTabs) - // — the merged display is the right sidebar alone, the bottom tabs thrown - // into its strips. Widening never rewrites the migrated state: the tabs - // keep living in the right tree. const viewport = useViewportSize() - const narrow = isNarrowWidth(viewport.width) // On-screen keyboard / visual-viewport inset (mobile, split-screen, …): // when the visual viewport shrinks below the layout viewport, bottom- @@ -177,7 +161,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // offset the bottom-anchored surfaces by it. The obscured bottom strip is // innerHeight − (vv.height + vv.offsetTop): offsetTop is nonzero while // the visual viewport is scrolled/zoomed under browser chrome, so - // omitting it would over-lift the panels (CR #232 P2). offsetTop changes + // omitting it would over-lift the panel (CR #232 P2). offsetTop changes // through the viewport's scroll event too, so both events are listened. // Guarded: browsers without visualViewport (older WebViews, jsdom) stay // at 0. rAF-throttled, same pattern as useNarrowViewport. @@ -203,9 +187,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { if (frame !== null) cancelAnimationFrame(frame) } }, []) - // The bottom panel is offset above the on-screen keyboard. Cap its height - // against that same visible area, not the taller layout viewport, so the - // conversation keeps PANEL_MIN even on wide touch devices. + // The bottom workbench is offset above the on-screen keyboard. Cap its + // height against that same visible area, not the taller layout viewport, + // so the conversation keeps CONVERSATION_MIN even on wide touch devices. const layoutViewportHeight = visualViewportHeight ?? viewport.height // Current conversation (the sessions list feed). @@ -225,27 +209,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { const state = snapshot.state const sessionId = snapshot.sessionId const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd - const pushedBottomHeight = (bottomOpen: boolean, bottomHeight: number): number => layoutPushSize({ - narrow, - panelOpen: false, - bottomOpen, - width: 0, - bottomHeight, - viewportWidth: viewport.width, - viewportHeight: layoutViewportHeight, - }).height - - // The collapsed toggle cluster reclaims the top-right corner, so the DSH - // session header's right-aligned utilities (the "Session log" download - // capsule) must yield. layout.css keys off this body attribute to push the - // header's right padding out past the cluster. Only the CLOSED panel needs - // it — an open panel reserves AppFrame padding, moving the header clear. - const collapsed = state === undefined || !state.panelOpen - useEffect(() => { - if (collapsed) document.body.setAttribute('data-dsh-sidebar-collapsed', '') - else document.body.removeAttribute('data-dsh-sidebar-collapsed') - return () => { document.body.removeAttribute('data-dsh-sidebar-collapsed') } - }, [collapsed]) // Title-bar / shell compatibility (the "位置兼容模式" scheme): // auto — CONSERVATIVE: only the standard Window Controls Overlay @@ -303,18 +266,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { return () => { for (const tag of tags) tag.remove() } }, [presetCss, customCss, preset?.id]) - /** - * Bottom-panel merge on narrow viewports: whenever a session is current - * while narrow (mount, session switch, or a desktop→narrow transition), - * throw the bottom tree's tabs into the right tree. Idempotent — after - * the first migration the bottom tree is empty and the reducer returns - * the same reference, so this effect settles immediately. - */ - useEffect(() => { - if (!narrow || sessionId === undefined) return - store.reduce(migrateBottomTabs) - }, [narrow, sessionId, store]) - // While the session's header is still hydrating (or the session is blank), // the list summary may carry no cwd; ask the host once (it falls back to // the process cwd) so the explorer root and terminal cwd are real from @@ -331,8 +282,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { }, [sessionId, summaryCwd]) const cwd = summaryCwd ?? fetchedCwd - // The + menu options ride a memo so the two Workbenches share ONE array - // identity across renders that did not change the store (drag state, + // The + menu options ride a memo so the workbench does not rebuild the + // array identity across renders that did not change the store (drag state, // viewport resize): fresh arrays per render re-rendered every LeafView's // + affordance whether or not anything tab-related moved. const newTabOptions = useMemo( @@ -350,19 +301,14 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // piece the render side consumes (renderTab's onSubagentJump arms it). const { subagentJumpRef } = useHostFeeds({ ctx, store, sessionList, sessionId }) - // Center-column tracking (sidebar/use-center-column.ts): the bottom panel - // spans ONLY the app shell's center column ("squeezes the agent output - // area"); the hook locates the AppFrame's center column DOM (host anchor - // observers + slow retry), measures its edges into a ref, and writes them - // straight to the bottom panel element — per-frame tracking without - // re-rendering the shell (the comments live with the hook now). + // Center-column tracking (sidebar/use-center-column.ts): the bottom + // workbench spans ONLY the app shell's center column ("squeezes the agent + // output area"); the hook locates the AppFrame's center column DOM (host + // anchor observers + slow retry), measures its edges into a ref, and + // writes them straight to the bottom panel element — per-frame tracking + // without re-rendering the shell (the comments live with the hook now). const bottomRef = useRef(null) - const { centerColRef, centerRectRef, centerMeasured, measureCenter, draggingRef } = useCenterColumn(bottomRef, state?.bottomOpen) - - // Free-window drag-out gesture (sidebar/free-windows.tsx): watches the - // document for OUR tab drags hovering the conversation column and floats - // the tab on release. Returns the drop-zone hint geometry for the layer. - const { floatHint } = useFloatDragout({ narrow, sessionId, store, centerColRef }) + const { centerRectRef, centerMeasured, measureCenter, draggingRef } = useCenterColumn(bottomRef, state?.bottomOpen) /** * Bottom-panel first-expansion auto terminal: the FIRST time the user @@ -377,10 +323,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { */ const bottomWasOpenRef = useRef(undefined) useEffect(() => { - // The bottom panel does not exist on narrow viewports (the two - // workbenches merge into one panel), so the first-expansion auto - // terminal is a desktop-only behavior. - if (narrow) return if (state === undefined) return const wasOpen = bottomWasOpenRef.current bottomWasOpenRef.current = state.bottomOpen @@ -391,103 +333,75 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // Land the tab in the bottom panel's first pane; the once-flag is set // atomically so later expansions never repeat the auto-open. store.reduce(s => ({ ...s, activePane: firstLeaf(s.bottomSplits).id, bottomOpenedOnce: true })) - ctx.get('betterSidebar')?.openTab({ type: 'terminal' }) - }, [state, store, ctx, narrow]) + ctx.get('betterSidebar')?.openTab({ type: 'terminal', target: 'bottom' }) + }, [state, store, ctx]) - // Panel drags: the right panel's width (left edge strip), the bottom - // panel's height (top edge strip), and the shared corner (both at once). - // Drags write the sizes DIRECTLY to the DOM (panel styles + the layout CSS - // variables) instead of round-tripping the store on every pointer move — - // a store reduce re-renders both workbenches (terminals, editors…) per - // move, which is the visible drag lag. The store is committed once on - // pointer up (clamping + persistence). - const panelRef = useRef(null) - const widthDrag = useRef({ startX: 0, startWidth: 0 }) - const [draggingWidth, setDraggingWidth] = useState(false) + // The bottom panel's height drag (top edge strip). Drags write the size + // DIRECTLY to the DOM (panel style + the layout CSS variable) instead of + // round-tripping the store on every pointer move — a store reduce + // re-renders the workbench (terminals, editors…) per move, which is the + // visible drag lag. The store is committed once on pointer up + // (clamping + persistence). const bottomDrag = useRef({ startY: 0, startHeight: 0 }) const [draggingBottom, setDraggingBottom] = useState(false) - const cornerDrag = useRef({ startX: 0, startY: 0, startWidth: 0, startHeight: 0 }) - const [draggingCorner, setDraggingCorner] = useState(false) - const anyDragging = draggingWidth || draggingBottom || draggingCorner // Pause center-column measurement while dragging, and re-measure once the // drag settles at its committed size. The store commit lands on release and - // the final width equals the last drag width, so no ResizeObserver event + // the final height equals the last drag height, so no ResizeObserver event // fires to refresh centerRect — this explicit re-measure covers that gap. useEffect(() => { - draggingRef.current = anyDragging - if (!anyDragging) measureCenter() + draggingRef.current = draggingBottom + if (!draggingBottom) measureCenter() // draggingRef is a stable ref object from useCenterColumn (same - // provenance note as the float effect above). - }, [anyDragging, measureCenter, draggingRef]) + // provenance note as the hook's own comments). + }, [draggingBottom, measureCenter, draggingRef]) - // Clamp mirrors of setWidth/setBottomHeight for mid-drag values (the store - // re-clamps on commit; these keep the panels from overshooting mid-drag). - const clampWidth = (width: number): number => - Math.min(Math.max(PANEL_MIN, Math.round(width)), Math.max(PANEL_MIN, window.innerWidth)) + // Clamp mirror of setBottomHeight for mid-drag values (the store re-clamps + // on commit; this keeps the panel from overshooting mid-drag). const clampHeight = (height: number): number => - Math.min(Math.max(BOTTOM_MIN, Math.round(height)), Math.max(BOTTOM_MIN, window.innerHeight - PANEL_MIN)) + Math.min(Math.max(BOTTOM_MIN, Math.round(height)), Math.max(BOTTOM_MIN, window.innerHeight - CONVERSATION_MIN)) - /** Single writer for the layout-push variables: the app shell gives up - * the panel's width/height while open (0 while collapsed) through - * layout.css's margins. Every size change — drag frames and committed - * state — flows through here so the push never forks between paths. */ - const writeGeometry = (width: number, height: number): void => { - document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`) + /** Single writer for the layout-push variable: the app shell gives up the + * workbench's height while it is open (0 while collapsed) through + * layout.css's margin on the center column. Every size change — drag + * frames and committed state — flows through here so the push never + * forks between paths. The right column is DSH's native Sidebar, so no + * width is written. */ + const writeGeometry = (height: number): void => { document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`) - // The corner handle positions itself relative to the panel (CSS - // `bottom: calc(var(--dsh-sidebar-height) + 6px)`), so these two layout - // variables are all it needs — no viewport coordinates written here - // (issue #106: skins that inset the panels must not fight JS coords). } - /** Last size a drag actually applied to the DOM (updated by applyDrag). + /** Last height a drag actually applied to the DOM (updated by applyDrag). * When a pointer stream dies without any position info (issue #247: an * ultra-fast flick whose release events carried no usable coordinates), * the abort path adopts this instead of rolling back to the pre-drag * value — the DOM's current size is the only truthful record left. */ - const lastDragSize = useRef<{ width: number; height: number } | null>(null) + const lastDragHeight = useRef(null) - /** Apply a drag size to the DOM without touching React state or the store. - * The bottom panel's right edge tracks the right panel's left edge HERE - * too — React state only updates on release, so the inline right must be - * written directly or the bottom panel would lag the sidebar mid-drag. - * The layout push rides the shared writer (writeGeometry). */ - const applyDrag = (width: number, height: number): void => { - lastDragSize.current = { width, height } - panelRef.current?.style.setProperty('width', `${width}px`) + /** Apply a drag height to the DOM without touching React state or the + * store. The layout push rides the shared writer (writeGeometry). */ + const applyDrag = (height: number): void => { + lastDragHeight.current = height bottomRef.current?.style.setProperty('height', `${height}px`) - // centerRect.right is the center column's right edge at the committed - // width (innerWidth - state.width - detailsWidth), so this equals - // `width + detailsWidth` — derived from the measured column, keeping the - // drag write-only (no React re-render mid-drag). - bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRectRef.current.right) + (width - (state?.width ?? 0))}px`) - const bottomPush = !narrow && state?.bottomOpen === true ? height + keyboardInset : 0 - // The pushed width must ride the same gate as the committed push effect - // (layoutPushSize): a collapsed right panel pushes 0. The bottom strip is - // the only drag reachable with the panel closed — writing the panel's - // persisted width preference here squeezed #root mid-drag, dropped the - // host viewport across its 1024px auto-collapse breakpoint, and the - // native left sidebar snapped to its 56px rail (and back on release). - const pushWidth = !narrow && state?.panelOpen === true ? Math.min(width, window.innerWidth) : 0 - writeGeometry(pushWidth, bottomPush) + const push = state?.bottomOpen === true ? height + keyboardInset : 0 + writeGeometry(push) } // Drags write at most once per frame: pointer events fire several times // faster than the display refresh, and each write reflows the app shell - // (the layout push) plus the panels — batching to one write per frame is + // (the layout push) plus the panel — batching to one write per frame is // what keeps the drag smooth. The store is still committed once on release. const dragFrame = useRef(null) - const pendingDrag = useRef<{ width: number; height: number } | null>(null) - const scheduleDrag = (width: number, height: number): void => { - pendingDrag.current = { width, height } + const pendingDrag = useRef(null) + const scheduleDrag = (height: number): void => { + pendingDrag.current = height if (dragFrame.current !== null) return dragFrame.current = requestAnimationFrame(() => { dragFrame.current = null const pending = pendingDrag.current if (pending !== null) { pendingDrag.current = null - applyDrag(pending.width, pending.height) + applyDrag(pending) } }) } @@ -503,27 +417,21 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { } /** - * Finalize a drag on pointer up: flush the LAST drag frame to the DOM - * synchronously, then commit the SAME clamped values to the store. A fast + * Finalize the drag on pointer up: flush the LAST drag frame to the DOM + * synchronously, then commit the SAME clamped value to the store. A fast * release cancels the rAF before it ran — without the flush the DOM would * sit at the pre-drag size until React re-renders with the committed * value, and a value that never made it into a move handler would never * be applied at all. The measurement pause ends here too: the center * column is re-measured BEFORE the committed re-render lands, so the - * bottom panel's React-rendered right edge already reflects the new - * width (otherwise the re-render would re-apply the stale rect — the - * bottom panel visibly jumps for one frame). + * panel's React-rendered edges already reflect the new height. */ - const commitDrag = ( - width: number, - height: number, - reduce: (state: SidebarState) => SidebarState, - ): void => { + const commitDrag = (height: number): void => { stopDragScheduling() - applyDrag(width, height) + applyDrag(height) draggingRef.current = false measureCenter() - store.reduce(reduce) + store.reduce(s => setBottomHeight(s, height)) } /** Set once a drag's pointerup handler commits — premature capture loss @@ -534,8 +442,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { * Abort a drag whose pointer stream was interrupted (pointercancel, or * capture lost before pointerup): no pointerup will arrive, so without * this the dragging state would stick true and center-column measurement - * would stay paused forever — the bottom panel freezes at stale edges and - * stops tracking sidebar/app-rail layout changes. + * would stay paused forever — the panel freezes at stale edges and stops + * tracking layout changes. * * A FAST release is the common trigger: browsers merge pointermove bursts, * and an ultra-fast flick can cancel the stream before ANY move lands. @@ -544,121 +452,78 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { * position (only pointercancel is trusted to carry coordinates — * lostpointercapture's coordinates are not guaranteed, so the handlers * pass the event only from pointercancel), and finally the size the drag - * last APPLIED to the DOM (lastDragSize). A drag that produced none of - * those (pure down+up at the same spot) commits the store's own sizes — - * a no-op, never an explicit rollback (issue #247: v0.13.1 never reverted - * an interrupted fast flick; the abort path added in the unified-host - * refactor did, and that regression is what this ordering removes). + * last APPLIED to the DOM (lastDragHeight). A drag that produced none of + * those (pure down+up at the same spot) commits the store's own height — + * a no-op, never an explicit rollback (issue #247). * * Every commit path marks the drag committed, so the interrupt * double-fire (pointercancel → lostpointercapture) cannot commit once * and then roll the same drag back. */ - const abortDrag = (reset: () => void, event?: { clientX: number; clientY: number }): void => { + const abortDrag = (reset: () => void, event?: { clientY: number }): void => { if (dragCommitted.current) return const pending = pendingDrag.current - let width: number | undefined let height: number | undefined if (pending !== null) { - width = pending.width - height = pending.height + height = pending } else if (event !== undefined) { // No move ever landed: the cancel position is all we have — commit it // (clamped) instead of rolling back the flick. - if (draggingWidth) { - width = clampWidth(widthDrag.current.startWidth + (widthDrag.current.startX - event.clientX)) - height = pushedBottomHeight(state?.bottomOpen === true, state?.bottomHeight ?? 0) - } else if (draggingBottom) { - width = Math.min(state?.width ?? 0, window.innerWidth) - height = pushedBottomHeight(true, clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY))) - } else if (draggingCorner) { - width = clampWidth(cornerDrag.current.startWidth + (cornerDrag.current.startX - event.clientX)) - height = pushedBottomHeight(true, clampHeight(cornerDrag.current.startHeight + (cornerDrag.current.startY - event.clientY))) - } + height = clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY)) } - if (width !== undefined && height !== undefined) { - dragCommitted.current = true - pendingDrag.current = null - if (dragFrame.current !== null) { - cancelAnimationFrame(dragFrame.current) - dragFrame.current = null - } - applyDrag(width, height) - draggingRef.current = false - measureCenter() - store.reduce(s => setBottomHeight(setWidth(s, width), height)) - } else { - // No pending write and no usable event coordinates: keep the size the - // drag last applied instead of rolling back to the pre-drag value - // (the flick's moves may have been consumed by the rAF just before - // the stream died — the DOM already shows the dragged size). Clamp to - // the current geometry like the layout-push effect (closed/narrow - // panels are written 0 by the push). + if (height !== undefined) { dragCommitted.current = true stopDragScheduling() - const last = lastDragSize.current - const { width: adoptedWidth, height: adoptedHeight } = layoutPushSize({ - narrow, - panelOpen: state?.panelOpen === true, - bottomOpen: state?.bottomOpen === true, - width: last?.width ?? state?.width ?? 0, - bottomHeight: last?.height ?? state?.bottomHeight ?? 0, - viewportWidth: viewport.width, - viewportHeight: layoutViewportHeight, - }) - applyDrag(adoptedWidth, adoptedHeight) + applyDrag(height) draggingRef.current = false measureCenter() - store.reduce(s => setBottomHeight(setWidth(s, adoptedWidth), adoptedHeight)) + store.reduce(s => setBottomHeight(s, height)) + } else { + // No pending write and no usable event coordinates: keep the height the + // drag last applied instead of rolling back to the pre-drag value (the + // flick's moves may have been consumed by the rAF just before the + // stream died — the DOM already shows the dragged size). + dragCommitted.current = true + stopDragScheduling() + const adopted = clampHeight(lastDragHeight.current ?? state?.bottomHeight ?? BOTTOM_MIN) + applyDrag(adopted) + draggingRef.current = false + measureCenter() + store.reduce(s => setBottomHeight(s, adopted)) } reset() } - // Layout push: the app shell gives up the panel's width/height while the - // panels are open (0 while collapsed), so the conversation and input bar - // are squeezed instead of covered. The margins are capped at the viewport - // so a stale persisted size (e.g. fullscreen on a bigger window) can never - // crush the app shell to zero. Dragging disables the layout transition. - // On NARROW viewports the drawer FLOATS over the app shell — no push, the - // conversation keeps the full width behind the drawer. + // Layout push: the app shell gives up the workbench's height while it is + // open (0 while collapsed), so the conversation and input bar are squeezed + // instead of covered. The margin is capped at the viewport so a stale + // persisted height (e.g. fullscreen on a bigger window) can never crush the + // app shell to zero. Dragging disables the layout transition. useEffect(() => { - const { width, height } = layoutPushSize({ - narrow, - panelOpen: snapshot.state?.panelOpen === true, - bottomOpen: snapshot.state?.bottomOpen === true, - width: snapshot.state?.width ?? 0, - bottomHeight: snapshot.state?.bottomHeight ?? 0, - viewportWidth: viewport.width, + const height = bottomPushHeight({ + open: snapshot.state?.bottomOpen === true, + height: snapshot.state?.bottomHeight ?? 0, viewportHeight: layoutViewportHeight, }) - const bottomPush = !narrow && snapshot.state?.bottomOpen === true - ? height + keyboardInset - : 0 - writeGeometry(width, bottomPush) - }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight, viewport.width, layoutViewportHeight, keyboardInset]) + writeGeometry(snapshot.state?.bottomOpen === true ? height + keyboardInset : 0) + }, [snapshot.state?.bottomOpen, snapshot.state?.bottomHeight, layoutViewportHeight, keyboardInset]) // Unmount must release the push (issue #31): when the boundary swaps the // whole sidebar after a render crash (or the plugin fiber is disposed / - // HMR), the CSS variables would otherwise stay on and layout.css + // HMR), the CSS variable would otherwise stay on and layout.css // keeps squeezing #root with a stale margin — "the sidebar cannot be // hidden" until a full reload. This lives in an UNMOUNT-ONLY effect, NOT // in the push effect's cleanup: React can yield between a passive - // effect's cleanup and setup phases, and removing the variables on a - // dependency change used to paint the push-less layout for a frame (the - // center column went full width) while the re-add restarted the margin - // transition — the bottom panel flashed full width after every width - // drag (issue #258). Keeping the variables continuously valid while - // mounted makes the push invisible to mid-flush style recals. + // effect's cleanup and setup phases, and removing the variable on a + // dependency change used to paint the push-less layout for a frame. useEffect(() => { return () => { - document.documentElement.style.removeProperty('--dsh-sidebar-width') document.documentElement.style.removeProperty('--dsh-sidebar-height') } }, []) useEffect(() => { - if (anyDragging) document.body.setAttribute('data-dsh-sidebar-dragging', '') + if (draggingBottom) document.body.setAttribute('data-dsh-sidebar-dragging', '') else document.body.removeAttribute('data-dsh-sidebar-dragging') - }, [anyDragging]) - + }, [draggingBottom]) const actions: WorkbenchActions = useMemo(() => ({ closeTab: (paneId, tabId) => { @@ -670,12 +535,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // TerminalView on unmount), and the agent-pty.close HTTP route is the // fallback when the WS is down. const current = store.getSnapshot().state - // Terminal tabs may live in EITHER tree (the bottom panel hosts them - // too) — the pty-release lookup covers both, or the HTTP fallback is - // skipped for a bottom-panel terminal whose WS frame never arrived. - const leaf = current === undefined - ? undefined - : leafWithTab(current.splits, tabId) ?? leafWithTab(current.bottomSplits, tabId) + const leaf = current === undefined ? undefined : leafWithTab(current.bottomSplits, tabId) const tab = leaf?.tabs.find(candidate => candidate.id === tabId) // Route through the service: the tab-bar close is the canonical close // path (finds the pane itself, fires descriptor.onClose); the session @@ -691,9 +551,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { } }, activateTab: (paneId, tabId) => { - // Route through the service: same reducer (finds the pane in EITHER - // tree, sets the active pane) and fires descriptor.onActivate; the - // session scope (with its cwd) rides to the callback. + // Route through the service: same reducer (finds the pane, sets the + // active pane) and fires descriptor.onActivate; the session scope + // (with its cwd) rides to the callback. ctx.get('betterSidebar')?.activateTab(tabId, sessionId === undefined ? undefined : { sessionId, cwd }) }, focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) }, @@ -703,7 +563,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => { store.reduce((s) => { let index = -1 - const source = leafWithTab(s.splits, beforeTabId) + const source = leafWithTab(s.bottomSplits, beforeTabId) if (source !== undefined && source.id === toPane) { index = source.tabs.findIndex(tab => tab.id === beforeTabId) } @@ -713,17 +573,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { resizeSplit: (splitId, index, deltaFrac) => { store.reduce(s => resizeSplitIn(s, splitId, index, deltaFrac)) }, - // The tab context menu's "move to free window": no drop point exists, so - // the window is born over the conversation column's center (the user's - // focus area; clamped into the viewport by the reducer) — the same - // landing the drag-out gesture produces. - floatTab: (tabId) => { - const col = centerColRef.current - const rect = col !== null && col.isConnected ? col.getBoundingClientRect() : null - const x = rect !== null ? (rect.left + rect.right) / 2 : window.innerWidth / 2 - const y = rect !== null ? (rect.top + rect.bottom) / 2 : window.innerHeight / 2 - store.reduce(s => floatTab(s, tabId, x, y)) - }, // Pin/unpin a terminal tab (v0.17.0+): the home cwd is snapshotted at // pin time so a workspace-scoped pin only resurfaces in sessions whose // cwd matches. Unpin passes null — the tab stays open in its home @@ -731,10 +580,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { pinTab: (tabId, scope) => { store.reduce(s => setTabPin(s, tabId, scope === null ? null : { scope, homeCwd: cwd })) }, - }), [store, sessionId, cwd, ctx, centerColRef]) + }), [store, sessionId, cwd, ctx]) // Pinned virtual tabs (sidebar/use-pinned-tabs.ts): cross-session pinned - // tabs inject into the right panel's first leaf, and the actions are + // tabs inject into the bottom workbench's first leaf, and the actions are // wrapped so pinned virtual ids route to the HOME session (reduceFor + // revision bump). const { augmentedTree, wrappedActions } = usePinnedTabs({ store, sessionId, cwd, snapshot, actions }) @@ -752,68 +601,34 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { */ const referenceInChat = useCallback((path: string, isDir: boolean): void => { if (sessionId === undefined) return - const rel = relativeTo(cwd ?? '', path) - if (isDir) { - appendToDraft(ctx, sessionId, `@${rel === '.' ? './' : `${rel}/`}`) - return - } - if (!insertFileReference(ctx, sessionId, rel)) { - appendToDraft(ctx, sessionId, `@${rel}`) - } + referenceInChatShared(ctx, sessionId, cwd, path, isDir) }, [ctx, sessionId, cwd]) if (state === undefined || sessionId === undefined) { - // Keep the unavailable controls focusable: touch users have no hover, so - // focus is the only way the existing Tooltip can explain what is missing. - return ( -
-
- {!narrow && ( - - - - )} - - - -
-
- ) + // No conversation yet: the host stays mounted (the drag shield keeps + // covering the region) but nothing is rendered — the toggle button lives + // in DSH's session header, which does not exist without a session. + return
} - const bottomPanelHeight = layoutPushSize({ - narrow, - panelOpen: state.panelOpen, - // Keep the hidden panel's geometry ready for its slide-in transition; - // only the layout push itself becomes zero while it is closed. - bottomOpen: true, - width: state.width, - bottomHeight: state.bottomHeight, - viewportWidth: viewport.width, + const bottomPanelHeight = bottomPushHeight({ + open: true, + height: state.bottomHeight, viewportHeight: layoutViewportHeight, - }).height + }) const onNewTab = (optionId: string): void => { const service = ctx.get('betterSidebar') const descriptor = service?.getTab(optionId) if (service === undefined || descriptor === undefined) return const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title - // The session scope rides along: lifecycle callbacks receive it (and - // the open stays in the current session, as before). - service.openTab({ type: optionId, title }, { sessionId, cwd }) + // The session scope rides along: lifecycle callbacks receive it. The + // open is bottom-targeted so the tab lands in THIS workbench, not in the + // native right Sidebar. + service.openTab({ type: optionId, title, target: 'bottom' }, { sessionId, cwd }) } - /** - * The explorer's @-reference button: append `@` to the - * session's composer draft (space-separated). Resolves the session-scope - * ctx and the conversation input service at click time; a missing service - * or scope degrades to a logged no-op, never a crash. - */ - /** The tab icon from the tab-type registry (shared by every workbench). */ + /** The tab icon from the tab-type registry. */ const tabIconOf = (tab: SidebarTab): ReactNode => { const descriptor = ctx.get('betterSidebar')?.getTab(tab.type) if (descriptor === undefined) return null @@ -856,13 +671,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { * polling while the page is not actually visible). The pane id travels * with the tab so diff tabs can split below their source pane. */ - // `placement` decides the visibility contract handed to the tab component: - // pane tabs are visible while their panel is open and they are active, but - // a free window is its own surface — its tab stays visible no matter what - // the panels do (the AGENTS §7.5 contract; plugin components honor - // `visible` to pause work, so tying floats to panelOpen would blank them - // the moment the sidebar collapses). - const renderTab = (tab: SidebarTab, active: boolean, paneId: string, placement: 'top' | 'bottom' | 'float' = 'top') => { + const renderTab = (tab: SidebarTab, active: boolean, paneId: string) => { // Pinned virtual tabs: pass the home session's scope (sessionId + cwd) so // TerminalView's WS URL resolves to the home PTY, and effectiveTabId so // the descriptor component receives the ORIGINAL tab id (the virtual id @@ -882,13 +691,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { onReferenceFile={referenceInChat} ctx={ctx} store={store} - visible={ - placement === 'float' - ? true - : placement === 'bottom' - ? state.bottomOpen && active - : state.panelOpen && active - } + visible={state.bottomOpen && active} onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }} onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }} localeRevision={localeRevision} @@ -900,182 +703,17 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { return (
{/* - The persistent toggle cluster at the top-right corner: the bottom - panel's button (bottom glyph) LEFT of the right panel's (side glyph). - Always pinned to the viewport corner — inside the right panel's - top-right while it is open, sitting flush in the tab strip whose - right end it really squeezes (the strip reserves its width via CSS), - so the tabs genuinely yield space to it. + The bottom workbench: it squeezes ONLY the center column (the agent + output area): it starts at the app shell's own left sidebar and ends + at the details column's left edge — neither sidebar gives up any + position. Its resize strip is the top edge; hidden by sliding down. + It only becomes VISIBLE once the center column is measured: before + that, `centerRect` is the {0,0} fallback and `right` computes to the + full viewport width — the panel (and its overflow content) would + flash full-width for a frame until the first measurement lands. + Rendering stays unconditional so the mount/render chain (auto-terminal + etc.) is never gated on geometry. */} -
- {/* - Narrow viewports merge the two workbenches into the one drawer — - there is no bottom panel, so its toggle button is not offered. - */} - {!narrow && ( - - - - )} - - - -
- {/* - The right panel stays mounted while collapsed (hidden off-screen) so - the slide in/out can animate; visibility hides it after the slide - settles. Its bottom edge follows the bottom panel's height (0 while - the bottom panel is closed) — the VSCode-style "sidebar above panel". - On NARROW viewports it is a full-width drawer holding both - workbenches (see MobileWorkbench); the width drag strip is not - offered there — a full-screen sheet has nothing to drag. - */} -
0 ? `${keyboardInset}px` : undefined, - }} - - data-dragging={anyDragging || undefined} - > - {!narrow && ( -
{ - event.preventDefault() - event.currentTarget.setPointerCapture(event.pointerId) - dragCommitted.current = false - widthDrag.current = { startX: event.clientX, startWidth: state.width } - setDraggingWidth(true) - }} - onPointerMove={(event) => { - if (!event.currentTarget.hasPointerCapture(event.pointerId)) return - const { startX, startWidth } = widthDrag.current - const width = clampWidth(startWidth + (startX - event.clientX)) - const height = pushedBottomHeight(state.bottomOpen, state.bottomHeight) - scheduleDrag(width, height) - }} - onPointerUp={(event) => { - if (!event.currentTarget.hasPointerCapture(event.pointerId)) return - if (dragCommitted.current) return - dragCommitted.current = true - event.currentTarget.releasePointerCapture(event.pointerId) - const { startX, startWidth } = widthDrag.current - // The up position is the pointer's FINAL position — a fast - // flick's tail is coalesced into the up event, so the last - // pointermove (the rAF pending value) can be stale. Commit - // from the up position (v0.13.1 semantics; issue #247). - const width = clampWidth(startWidth + (startX - event.clientX)) - const height = pushedBottomHeight(state.bottomOpen, state.bottomHeight) - commitDrag(width, height, s => setWidth(s, width)) - setDraggingWidth(false) - }} - onPointerCancel={(event) => { abortDrag(() => setDraggingWidth(false), event) }} - onLostPointerCapture={() => { abortDrag(() => setDraggingWidth(false)) }} - /> - )} -
- -
- {/* - The shared corner (only while BOTH panels are open): the - intersection of the right panel's left edge and the bottom panel's - top edge. Horizontal drags resize the right panel's width, vertical - drags the bottom panel's height — the two panels drag against each - other. Rendered INSIDE the right panel and positioned by CSS - relative to it (left edge + the bottom panel's height via the - --dsh-sidebar-height layout variable) — no JS-written viewport - coordinates to keep in sync. (Never on narrow viewports: the - bottom panel does not exist there.) - */} - {!narrow && state.panelOpen && state.bottomOpen && ( -
{ - event.preventDefault() - event.currentTarget.setPointerCapture(event.pointerId) - dragCommitted.current = false - cornerDrag.current = { - startX: event.clientX, - startY: event.clientY, - startWidth: state.width, - startHeight: state.bottomHeight, - } - setDraggingCorner(true) - }} - onPointerMove={(event) => { - if (!event.currentTarget.hasPointerCapture(event.pointerId)) return - const { startX, startY, startWidth, startHeight } = cornerDrag.current - const width = clampWidth(startWidth + (startX - event.clientX)) - const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY))) - scheduleDrag(width, height) - }} - onPointerUp={(event) => { - if (!event.currentTarget.hasPointerCapture(event.pointerId)) return - if (dragCommitted.current) return - dragCommitted.current = true - event.currentTarget.releasePointerCapture(event.pointerId) - const { startX, startY, startWidth, startHeight } = cornerDrag.current - // Up position wins over the rAF pending value (see the width - // strip handler — issue #247). - const width = clampWidth(startWidth + (startX - event.clientX)) - const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY))) - commitDrag(width, height, s => setBottomHeight(setWidth(s, width), height)) - setDraggingCorner(false) - }} - onPointerCancel={(event) => { abortDrag(() => setDraggingCorner(false), event) }} - onLostPointerCapture={() => { abortDrag(() => setDraggingCorner(false)) }} - /> - )} -
- {/* - The bottom panel: a second, independent workbench. It squeezes ONLY - the center column (the agent output area): it starts at the app - shell's own left sidebar and ends at the right panel's left edge — - neither sidebar gives up any position (the right panel keeps its - full height). Its resize strip is the top edge; hidden by sliding - down like the right panel. On NARROW viewports it does not exist — - the bottom workbench lives inside the drawer (MobileWorkbench). - */} - {/* The bottom panel only becomes VISIBLE once the center column is - measured: before that, `centerRect` is the {0,0} fallback and - `right` computes to the full viewport width — the panel (and its - overflow content) would flash full-width for a frame until the - first measurement lands. Rendering stays unconditional so the - mount/render chain (auto-terminal etc.) is never gated on - geometry. */} - {!narrow && (
0 ? `${keyboardInset}px` : undefined, // Direct from the center column's measured right edge: the bottom // panel spans ONLY the center column, ending exactly at the - // details column's left edge (the details column sits between the - // center and the right panel, and the right panel's reserved frame - // padding is already baked into centerRect.right). + // details column's left edge. right: window.innerWidth - centerRectRef.current.right, - // The seam against the open right panel needs its own hairline - // (the right panel's border-left alone is covered by this panel's - // fill — without it the corner looks cut off). - borderRight: state.panelOpen ? '1px solid var(--dsw-alias-border-l2)' : undefined, // Unmeasured center column → keep the panel invisible (zero-size // geometry would flash full-width overflow instead). visibility: centerMeasured ? undefined : 'hidden', }} - - data-dragging={(draggingBottom || draggingCorner) || undefined} + data-dragging={draggingBottom || undefined} >
{ event.preventDefault() event.currentTarget.setPointerCapture(event.pointerId) @@ -1117,8 +747,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { onPointerMove={(event) => { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return const { startY, startHeight } = bottomDrag.current - const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY))) - scheduleDrag(Math.min(state.width, window.innerWidth), height) + scheduleDrag(clampHeight(startHeight + (startY - event.clientY))) }} onPointerUp={(event) => { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return @@ -1126,10 +755,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { dragCommitted.current = true event.currentTarget.releasePointerCapture(event.pointerId) const { startY, startHeight } = bottomDrag.current - // Up position wins over the rAF pending value (see the width - // strip handler — issue #247). - const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY))) - commitDrag(Math.min(state.width, window.innerWidth), height, s => setBottomHeight(s, height)) + // Up position wins over the rAF pending value (see the abortDrag + // comment — issue #247). + commitDrag(clampHeight(startHeight + (startY - event.clientY))) setDraggingBottom(false) }} onPointerCancel={(event) => { abortDrag(() => setDraggingBottom(false), event) }} @@ -1153,33 +781,43 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
renderTab(tab, active, paneId, 'bottom')} + renderTab={renderTab} getTabIcon={tabIconOf} getTabBadge={tabBadgeOf} />
- )} - {/* - Free windows + the drag-out hint: the layer renders the floats (the - array's order is the stacking order) reusing the regular tab - renderer, followed by the dashed drop-zone overlay (sidebar/ - free-windows.tsx renders both as one fragment — DOM unchanged). - */} -
) } + +/** The header control that expands/collapses the bottom workbench (see + * sidebar/bottom-toggle.tsx — registered into DSH's session header). */ +export function BottomDockToggle(props: { store: SidebarStore }) { + const { store } = props + const snapshot = useSyncExternalStore( + useCallback((callback: () => void) => store.subscribe(callback), [store]), + useCallback(() => store.getSnapshot(), [store]), + ) + const open = snapshot.state?.bottomOpen === true + const label = open ? t('collapseBottomPanel') : t('expandBottomPanel') + return ( + + + + ) +} diff --git a/src/client/TabBar.tsx b/src/client/TabBar.tsx index bbffc13..7d5ce44 100644 --- a/src/client/TabBar.tsx +++ b/src/client/TabBar.tsx @@ -4,9 +4,8 @@ * button cluster, and the + menu that opens new tabs (explorer / git / * terminal). Tabs are draggable; dropping onto another tab inserts before it, * dropping on the strip background appends to this pane. Right-clicking a - * tab opens the tab context menu (float as a free window / close / close - * others / close to the left / close to the right, the close ones scoped to - * this pane). + * tab opens the tab context menu (close / close others / close to the left / + * close to the right, the close ones scoped to this pane). */ import { useEffect, useRef, useState, type ReactNode } from 'react' import clsx from 'clsx' @@ -68,9 +67,6 @@ export function TabBar(props: { newTabOptions: NewTabOption[] /** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */ onDropTab: (payload: TabDragPayload, before: string | null) => void - /** Float a tab out as a free window (the tab context menu's entry; the - * drag-to-conversation gesture is handled at the Sidebar shell level). */ - onFloatTab: (tabId: string) => void /** * Pin/unpin a terminal tab (v0.17.0+). Called with `'workspace'` or * `'global'` to pin (the shell snapshots the home cwd), or `null` to @@ -85,7 +81,7 @@ export function TabBar(props: { getTabBadge?: (tab: SidebarTab) => ReactNode }) { const { - paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, onFloatTab, onPinTab, getTabIcon, getTabBadge, + paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, onPinTab, getTabIcon, getTabBadge, } = props const [menuOpen, setMenuOpen] = useState(false) // The tab right-click context menu: the target tab plus the cursor @@ -298,9 +294,8 @@ export function TabBar(props: { // The target tab drives the pin entry's shape: terminal tabs // get either a "Pin ▸" submenu (unpinned) or a single "Unpin" // row (pinned). Non-terminal tabs and missing onPinTab get no - // pin entry at all — the menu stays exactly the legacy 5-item - // shape. Pinned VIRTUAL tabs (injected from other sessions) - // get a stripped menu: only Unpin + Close (no float, no + // pin entry at all. Pinned VIRTUAL tabs (injected from other + // sessions) get a stripped menu: only Unpin + Close (no // close-others/left/right — those are pane-scoped operations // that don't apply to cross-session virtual tabs). const targetTab = tabMenuIndex >= 0 ? tabs[tabMenuIndex] : undefined @@ -325,7 +320,6 @@ export function TabBar(props: { ] } return [ - { id: 'float', label: t('moveToFreeWindow') }, ...pinEntries, { id: 'close', label: t('close') }, { id: 'closeOthers', label: t('closeOtherTabs'), ...(tabs.length <= 1 ? { disabled: true } : {}) }, @@ -339,9 +333,7 @@ export function TabBar(props: { setTabMenu(null) const index = tabs.findIndex(tab => tab.id === target.tabId) if (index < 0) return - if (id === 'float') { - onFloatTab(target.tabId) - } else if (id === 'pinWorkspace') { + if (id === 'pinWorkspace') { onPinTab?.(target.tabId, 'workspace') } else if (id === 'pinGlobal') { onPinTab?.(target.tabId, 'global') diff --git a/src/client/api.ts b/src/client/api.ts index 677c44b..29d2169 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -8,7 +8,7 @@ */ import { encodeHtmlUrl } from '../html-route.ts' import type { LastActivity } from '../subagent-activity.ts' -import type { SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts' +import type { SidechatLiveEvent, SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts' import type { SidebarSessionEvent } from '../context-types.ts' import type { BrowserProbeResult } from './browser.ts' @@ -394,9 +394,12 @@ export const api = { call('sidechat.info', { childId }), /** One transcript pull of a Side Chat thread: the thread's OWN events * (the inherited seed is cut host-side and never crosses the wire). - * `afterSeq` narrows the response to the delta beyond it (poll tail). */ + * `afterSeq` narrows the response to the delta beyond it (poll tail). + * `live` is the thread's in-flight model deltas (DSH 0.1.5 publishes them + * outside the session log) — the CURRENT attempt on every pull, never a + * delta, so the caller replaces its live set instead of appending. */ sidechatEvents: (childId: string, afterSeq?: number, signal?: AbortSignal) => - call<{ events: SidechatLogEvent[] }>('sidechat.events', { + call<{ events: SidechatLogEvent[]; live: SidechatLiveEvent[] }>('sidechat.events', { childId, ...(afterSeq !== undefined ? { afterSeq } : {}), }, signal), diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx index 1a73d66..0982808 100644 --- a/src/client/builtins/tabs.tsx +++ b/src/client/builtins/tabs.tsx @@ -22,7 +22,7 @@ import { SubagentView } from '../SubagentView.tsx' import { consumeSidechatSeed, SideChatView, sidechatThreadIdOf } from '../SideChatView.tsx' import { api } from '../api.ts' import { BrowserView } from '../BrowserView.tsx' -import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16, IconFloatWindowOutline16, IconPanelBottomOutline16 } from '../icons.tsx' +import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx' import { TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../../prefs-shared.ts' import type { ComponentType } from 'react' import type { SessionScope } from '../api.ts' @@ -72,7 +72,7 @@ function terminalUuid(): string { /** Count UI-owned terminals (agent:` tabs excluded — they are the model's). */ function uiTerminalCount(state: SidebarState): number { - return allLeaves(state.splits) + return allLeaves(state.bottomSplits) .flatMap(leaf => leaf.tabs) .filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length } @@ -82,6 +82,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read return [ { id: 'editor', + description: () => t('guideDescFiles'), // The single files window: an editor tab with no path IS the file // explorer (empty hint + docked tree); with a path it previews/edits // the file. Visible in the + menu in the explorer's old slot. @@ -148,6 +149,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read // log, and the git status needs a fetch — both stay out of the badge). id: 'git', title: () => t('changes'), + description: () => t('guideDescGit'), icon: (size: number) => , order: 20, single: true, @@ -155,31 +157,6 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read const count = opCountOf(scope.sessionId) return count === undefined || count === 0 ? null : count }, - // Declarative settings: the diff-open picker (free window vs docked - // pane) renders as an iconed select row under the changes card's gear - // in the Side card settings page. - settings: { - toggles: [{ - key: 'changesDiffFloat', - type: 'select', - title: () => t('changesDiffOpenTitle'), - desc: () => t('changesDiffOpenDesc'), - options: [ - { - value: true, - icon: (size: number) => , - title: () => t('changesDiffOpenFloat'), - desc: () => t('changesDiffOpenFloatDesc'), - }, - { - value: false, - icon: (size: number) => , - title: () => t('changesDiffOpenPane'), - desc: () => t('changesDiffOpenPaneDesc'), - }, - ], - }], - }, component: ({ ctx, store, scope, tab, visible, onOpenDiff }) => ( t('subagent'), + description: () => t('guideDescSubagent'), icon: (size: number) => , order: 30, single: true, @@ -223,6 +201,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read { id: 'sidechat', title: () => t('sideChat'), + description: () => t('guideDescSidechat'), icon: (size: number) => , order: 35, // Codex-style: EVERY side conversation is its own tab. A plain open @@ -268,6 +247,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read { id: 'terminal', title: () => t('terminal'), + description: () => t('guideDescTerminal'), icon: (size: number) => , order: 40, available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT, @@ -332,6 +312,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read { id: 'browser', title: () => t('browser'), + description: () => t('guideDescBrowser'), icon: (size: number) => , order: 50, // Declarative settings: the sandbox escape hatch, the link-takeover diff --git a/src/client/center-column.ts b/src/client/center-column.ts index 5bf7e73..b4d8c95 100644 --- a/src/client/center-column.ts +++ b/src/client/center-column.ts @@ -1,5 +1,15 @@ -/** Stable selector for the DSH AppFrame conversation slot. */ -const CENTER_COLUMN_SELECTOR = '#root [data-slot="conversation"]' +/** + * Anchor selectors for the DSH AppFrame conversation slot host, newest first: + * + * - DSH 0.1.5-alpha.2 replaced the root `conversation` slot with a root-scoped + * keyed `main` slot, so the conversation renders under `main.conversation` + * (ConversationPanel -> `renderSlot('main.conversation')`); + * - alpha.1 and earlier spell the same host `conversation`. + * + * Both keys are accepted deliberately so one build spans the alpha.1 → alpha.2 + * boundary; only one exists in any given host. + */ +const CENTER_COLUMN_SELECTOR = '#root [data-slot="main.conversation"], #root [data-slot="conversation"]' /** Match Sidebar's existing last-resort retry cadence (issue #248). */ export const CENTER_COLUMN_REVALIDATE_MS = 1500 @@ -27,6 +37,22 @@ const validatedAt = new WeakMap() */ const documentStyleState = new WeakMap() +/** + * From the matched conversation slot host, walk up through transparent slot + * hosts to the real column: DSH slot hosts render with `display: contents`, + * so on alpha.2 the `main.conversation` host (and any further keyed slot + * wrappers above it) contributes no box, and the center column is the first + * ancestor whose computed display is NOT `contents`. + */ +function columnOfSlotHost(host: Element): HTMLElement | null { + const view = host.ownerDocument.defaultView + let node = host.parentElement + while (node !== null && (view?.getComputedStyle(node).display ?? '') === 'contents') { + node = node.parentElement + } + return node +} + /** * Resolve the AppFrame center column while keeping streaming mutations cheap. * @@ -74,7 +100,8 @@ export function resolveCenterColumn( } const query = options.query ?? (() => doc.querySelector(CENTER_COLUMN_SELECTOR)) - const col = query()?.parentElement as HTMLElement | null | undefined + const host = query() + const col = host === null || host === undefined ? null : columnOfSlotHost(host) if (col === null || col === undefined || !col.isConnected) return undefined validatedAt.set(col, now) diff --git a/src/client/changes/ChangesTab.tsx b/src/client/changes/ChangesTab.tsx index e7cb963..a18cacb 100644 --- a/src/client/changes/ChangesTab.tsx +++ b/src/client/changes/ChangesTab.tsx @@ -3,9 +3,9 @@ * (repository truth: staged/unstaged files, commit box, history) and the * session round (agent truth: every file the model read, wrote, or edited). * Both lenses preview their selections in a shared resizable bottom pane - * ({@link DiffPane}); git targets expand into the dedicated diff tab — - * docked in a pane, or floated as a free window per the tab's setting. - * The active lens and the pane height persist in the tab's meta, so the tab + * ({@link DiffPane}); git targets expand into the dedicated diff tab docked + * in the workbench's diff pane. The active lens and the pane height persist + * in the tab's meta, so the tab * reopens exactly where it was left. * * The session events ride the host's `changes.ops` route (the client @@ -19,7 +19,7 @@ import type { TabComponentProps } from '../service.ts' import { t } from '../locales.ts' import { api } from '../api.ts' import { usePolling } from '../use-polling.ts' -import { floatTab, type SidebarDiffRef } from '../state.ts' +import type { SidebarDiffRef } from '../state.ts' import { GitLens } from './GitLens.tsx' import { SessionLens } from './SessionLens.tsx' import { DiffPane, diffTabOf, type ChangesPreview } from './DiffPane.tsx' @@ -137,18 +137,11 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen setPreview({ kind: 'op', path, op, prior: knownContentBefore(ops, path, op) }) } - /** Expand the current git preview into the dedicated diff tab: docked - * into the shell's diff pane, or floated as a free window centered on - * the viewport when the tab's diff-open setting asks for it (default). */ + /** Expand the current git preview into the dedicated diff tab, docked + * into the workbench's diff pane. */ const expandPreview = (): void => { if (preview?.kind !== 'git') return - const diffTab = diffTabOf(preview.ref) - onOpenDiff?.(diffTab) - if (store.getPrefs().changesDiffFloat !== false) { - const x = Math.round(window.innerWidth / 2) - const y = Math.round(window.innerHeight / 2) - store.reduce(state => floatTab(state, diffTab.id, x, y)) - } + onOpenDiff?.(diffTabOf(preview.ref)) } const previewKey = (target: ChangesPreview): string => target.kind === 'git' diff --git a/src/client/icons.tsx b/src/client/icons.tsx index bd728f6..3ea06b2 100644 --- a/src/client/icons.tsx +++ b/src/client/icons.tsx @@ -1,24 +1,12 @@ /** * Icons the sidebar needs beyond the primitives set: a terminal glyph (the - * icon library has none), a diff glyph, and the two panel-toggle glyphs for - * the top-right cluster. Per-tab icons live on the tab descriptors + * icon library has none), a diff glyph, and the bottom-workbench toggle. + * Per-tab icons live on the tab descriptors * (`descriptor.icon`), not in a type-keyed switch — the icon mapping was * registry-ized with the tab types. */ import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' -/** - * Right-panel toggle glyph (the "侧拉" button): a frame with a filled strip - * along its RIGHT edge, in the app's outline style (1.5px stroke, - * currentColor). - */ -export const IconPanelRightOutline16 = ({ size = 16, className }: IconProps) => ( - - - - -) - /** * Bottom-panel toggle glyph (the "底栏" button): a frame with a filled strip * along its BOTTOM edge, in the app's outline style. @@ -161,15 +149,3 @@ export const IconVscode16 = ({ size = 16, className }: IconProps) => ( ) - -/** - * Free-window glyph in the app's outline style (1.5px stroke, currentColor): - * a background frame with a detached rounded mini-window floating over its - * top-right — the changes tab's "diff opens as a free window" setting. - */ -export const IconFloatWindowOutline16 = ({ size = 16, className }: IconProps) => ( - - - - -) diff --git a/src/client/index.tsx b/src/client/index.tsx index aed2ee8..d5c82a3 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -17,7 +17,11 @@ import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-load import { registerBuiltins } from './builtins/index.ts' import { Sidebar } from './Sidebar.tsx' import { RenderBoundary } from './RenderBoundary.tsx' -import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx' +import { registerTurnTailInterception } from './intercept.tsx' +import { createNativeTabRecords } from './native/tab-adapter.tsx' +import { registerNativeSurface } from './native/index.ts' +import { registerBottomToggle } from './sidebar/bottom-toggle.tsx' +import { createNativeSurface } from './native/surface.ts' import { registerLinkInterception } from './link-intercept.ts' import { registerImeGuard } from './ime-guard.ts' import { registerSettingsNavIcon } from './settings-nav-icon.ts' @@ -137,6 +141,27 @@ export function apply(ctx: Context): void { // are ready by the time the sidebar renders. const service = createBetterSidebarService(sidebarStore) ctx.provide('betterSidebar', service) + // The native right-Sidebar surface: the plugin's content is registered as + // DSH tab types (one per descriptor) and every open routes there, so the + // right column belongs to the host and only the bottom workbench stays + // plugin-owned. Both halves live for this fiber's lifetime. + const nativeRecords = createNativeTabRecords() + const nativeSurface = createNativeSurface(ctx, nativeRecords) + service.setSurface(nativeSurface) + ctx.effect( + () => registerNativeSurface({ ctx, store: sidebarStore, service, records: nativeRecords }), + 'dsh-better-sidebar: native right-Sidebar registrations', + ) + // The bottom workbench's expand/collapse button in DSH's session header + // (the header's corner seat belongs to the native sidebar's own control). + ctx.effect( + () => registerBottomToggle(ctx, sidebarStore), + 'dsh-better-sidebar: bottom-workbench toggle', + ) + ctx.effect( + () => () => { nativeSurface.dispose(); service.setSurface(undefined) }, + 'dsh-better-sidebar: native right-Sidebar surface', + ) // Terminal tab titles use the host's effective shell name (e.g. bash/zsh) // instead of "Terminal 1". Start with a safe fallback and replace it as // soon as the host shell info resolves. Tabs created before the response @@ -148,8 +173,7 @@ export function apply(ctx: Context): void { terminalTitle = name const snapshot = service.getSnapshot() if (snapshot.state === undefined) return - const tabs = allLeaves(snapshot.state.splits) - .concat(allLeaves(snapshot.state.bottomSplits)) + const tabs = allLeaves(snapshot.state.bottomSplits) .flatMap(leaf => leaf.tabs) for (const tab of tabs) { if (tab.type === 'terminal' && !isAgentTabId(tab.id) && tab.title === fallbackTitle) { @@ -342,18 +366,6 @@ export function apply(ctx: Context): void { 'dsh-better-sidebar: turn-tail interception', ) - ctx.effect( - () => { - try { - return registerOpenPathInterception(ctx, sidebarStore) - } catch (error) { - fail('interception', error) - return () => {} - } - }, - 'dsh-better-sidebar: open-path interception', - ) - ctx.effect( () => { try { diff --git a/src/client/intercept.tsx b/src/client/intercept.tsx index 8630a8b..b4f130b 100644 --- a/src/client/intercept.tsx +++ b/src/client/intercept.tsx @@ -8,10 +8,9 @@ */ import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from '../context-types.ts' -import { firstLeaf, revealPaths, togglePanel, type SidebarStore } from './state.ts' +import { revealPaths, type SidebarStore } from './state.ts' import { t } from './locales.ts' import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts' -import { wrapOpenWorkspacePath, type OpenWorkspacePathService } from './openpath-intercept.ts' import css from './sidebar.module.css' /** Open a file in the sidebar's editor (used by the intercepted row and the explorer). */ @@ -25,18 +24,10 @@ export function openSidebarFile(ctx: Context, store: SidebarStore, sessionId: st ctx.get('betterSidebar')?.openTab({ type: 'editor', title, path: absolute, id: `editor:${absolute}` }) } -/** - * The produced files the turn-tail selector last matched for the visible - * session. The "Show in folder" gesture carries no file path of its own - * (`'.'`), so the reveal highlights exactly these rows when available. - */ -let lastProduced: readonly string[] = [] - /** * Reveal the produced files in the sidebar explorer: expand their parent - * directories, highlight the rows, and focus the explorer tab (expanding the - * hosting panel when it is collapsed). Unknown files fall back to revealing - * the workspace root itself. + * directories, highlight the rows, and focus the explorer tab. Unknown + * files fall back to revealing the workspace root itself. */ export function revealInExplorer( ctx: Context, @@ -53,15 +44,6 @@ export function revealInExplorer( ? files.map(path => resolveSidebarPath(cwd, path)) : cwd === undefined ? [] : [cwd] store.reduce(state => revealPaths(state, cwd, targets)) - // A type-only open never auto-expands the panel (only content opens do, - // see service.openTab) — so a reveal opens the panel itself when it is - // collapsed, exactly like the subagent auto-open flows, or the highlight - // would be set on an invisible panel. - store.reduce(s => (s.panelOpen ? s : togglePanel(s))) - // Pin the landing to the right panel: the files window must appear where - // the panel just expanded, not in a bottom-panel pane the user last - // touched. - store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id })) // Focus the single-instance editor home tab (the files window) where the // reveal highlight renders. Read via ctx.get like every other internal // consumer (#357): the provider is not on this fiber chain, so a direct @@ -137,9 +119,7 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore): select: (owner) => { if (store.getSuspended()) return null if (store.getPrefs().tabsEnabled['editor'] === false) return null - const matched = selectProducedFiles(owner) - if (matched !== null) lastProduced = matched - return matched + return selectProducedFiles(owner) }, priority: -1, registrant: 'dsh-better-sidebar', @@ -149,38 +129,3 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore): }), }, SidebarProducedFiles)) } - -/** - * Register the chat file-open interception: shadows - * `remote.session.openWorkspacePath` — the single funnel every chat-side - * file open goes through on alpha hosts (tool-row path links, the - * produced-files row, prose mentions, inline-code paths) — so opens land in - * the sidebar editor instead of the Host OS. The folder-reveal gesture - * ("Show in folder" passes `'.'`) is the one exception: it is routed to the - * explorer. Gated by BOTH the `interceptOpenPath` pref and the editor tab's - * enable switch; declined opens fall through to the original remote call. - * - * The `remote.session` namespace service mounts asynchronously (the gateway - * client creates it when the session-controller contribution arrives) and - * is recreated on contribution remounts, so the wrapper installs through - * `ctx.inject`: the callback runs once the service exists and re-runs after - * every remount, re-applying the shadow on the fresh instance. Returns the - * disposer (disposes the inject fiber, which restores the original method - * descriptor — HMR-safe). - */ -export function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void { - const fiber = ctx.inject(['remote.session'], (fctx) => { - fctx.effect(() => { - const service = fctx.get('remote.session') as OpenWorkspacePathService - return wrapOpenWorkspacePath(service, { - takeoverEnabled: () => !store.getSuspended() - && store.getPrefs().interceptOpenPath !== false - && store.getPrefs().tabsEnabled['editor'] !== false, - currentSessionId: () => ctx.sessions.list.getSnapshot().current, - openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) }, - revealInExplorer: (_path, sessionId) => { revealInExplorer(ctx, store, sessionId, lastProduced) }, - }) - }, 'dsh-better-sidebar: open-path interception wrap') - }) - return () => { void fiber.dispose() } -} diff --git a/src/client/layout-push.ts b/src/client/layout-push.ts index 8b53cb2..b6b88b9 100644 --- a/src/client/layout-push.ts +++ b/src/client/layout-push.ts @@ -1,37 +1,28 @@ /** - * Size written to `--dsh-sidebar-width` / `--dsh-sidebar-height`. - * The conversation column (output + composer) must keep at least - * {@link PANEL_MIN} of the viewport after the bottom panel claims height. + * Size written to `--dsh-sidebar-height`. The conversation column (output + + * composer) must keep at least {@link CONVERSATION_MIN} of the viewport after + * the bottom workbench claims height; a closed workbench pushes 0. The right + * column belongs to DSH's native Sidebar, so this shell pushes no width. */ -import { PANEL_MIN } from './state.ts' +import { CONVERSATION_MIN } from './state.ts' -export interface LayoutPushInput { - narrow: boolean - panelOpen: boolean - bottomOpen: boolean - width: number - bottomHeight: number - viewportWidth: number - viewportHeight: number -} - -export interface LayoutPushSize { - width: number +export interface BottomPushInput { + /** Whether the bottom workbench is expanded. */ + open: boolean + /** Committed (or mid-drag) bottom height in px. */ height: number + /** Viewport height the cap is measured against (visual viewport when known). */ + viewportHeight: number } function finiteNonNegative(value: number): number { return Number.isFinite(value) ? Math.max(0, value) : 0 } -/** Compute the live layout-push size. Narrow drawers float and push 0. */ -export function layoutPushSize(input: LayoutPushInput): LayoutPushSize { - if (input.narrow) return { width: 0, height: 0 } - const viewportWidth = finiteNonNegative(input.viewportWidth) +/** Compute the live layout-push height for the bottom workbench. */ +export function bottomPushHeight(input: BottomPushInput): number { + if (!input.open) return 0 const viewportHeight = finiteNonNegative(input.viewportHeight) - const maxHeight = Math.max(0, viewportHeight - Math.min(PANEL_MIN, viewportHeight)) - return { - width: input.panelOpen ? Math.min(finiteNonNegative(input.width), viewportWidth) : 0, - height: input.bottomOpen ? Math.min(finiteNonNegative(input.bottomHeight), maxHeight) : 0, - } + const maxHeight = Math.max(0, viewportHeight - Math.min(CONVERSATION_MIN, viewportHeight)) + return Math.min(finiteNonNegative(input.height), maxHeight) } diff --git a/src/client/layout.css b/src/client/layout.css index f497840..3a3e798 100644 --- a/src/client/layout.css +++ b/src/client/layout.css @@ -1,47 +1,25 @@ /** - * Layout push: an open panel occupies layout instead of floating over it. - * The right panel reserves padding inside the AppFrame three-column grid, - * so the app sidebar, conversation, and details column all remain to its - * left while the frame's border-box stays viewport-wide. Harness measures - * that border-box to choose desktop versus narrow presentation; preserving - * it prevents a desktop window from entering narrow mode merely because the - * plugin panel opened. + * Layout push: the bottom workbench occupies layout instead of floating over + * the conversation. The right column belongs to DSH's native Sidebar, so this + * stylesheet pushes no width — only the workbench's height. * - * AppFrame positions its details drag handle from the measured border-box. - * Move that handle left by the same reserved width so it stays on the details - * column edge. The ordinary sidebar handle remains at its native coordinate. - * - * The bottom panel squeezes only the conversation column. The anchor is the + * The workbench squeezes only the conversation column. The anchor is the * [data-dsh-center-col] tag the Sidebar shell's locator writes on the * frame's measured center grid item (Sidebar.tsx locate — not a positional * path: the frame also contains an overlay layer and drag handles). A * stretched grid item shrinks by its bottom margin, so the conversation * output and composer lift without touching either sidebar. * - * Sizes ride CSS variables updated by the Sidebar shell (0 while collapsed). - * Expand/collapse uses the host theme duration; drags disable transitions so - * every reserved edge tracks the pointer. + * The size rides a CSS variable updated by the Sidebar shell (0 while + * collapsed). Expand/collapse uses the host theme duration; drags disable + * transitions so the reserved edge tracks the pointer. */ -/* Current shells expose [data-dsh-frame]; rc.8-era shells expose the same - AppFrame only as the root slot's direct child. */ -#root [data-dsh-frame], -#root > [data-slot="root"] > div { - box-sizing: border-box; - padding-right: var(--dsh-sidebar-width, 0px); - transition: padding-right var(--ds-transition-duration-slow) var(--ds-ease-in-out); -} - -#root [data-dsh-frame] > [data-side="details"], -#root > [data-slot="root"] > div > [data-side="details"] { - transform: translateX(calc(0px - var(--dsh-sidebar-width, 0px))); - transition: - left var(--ds-transition-duration-slow) var(--ds-ease-in-out), - transform var(--ds-transition-duration-slow) var(--ds-ease-in-out); -} - /* The AppFrame's center column. The Sidebar shell's locator (Sidebar.tsx - locate/measureCenter) tags the measured column node with - [data-dsh-center-col] — one attribute write per node lifetime. The rule + locate/measureCenter, resolving via center-column.ts) tags the measured + column node with [data-dsh-center-col] — one attribute write per node + lifetime. The locator anchors on the conversation slot host + (`main.conversation` on DSH 0.1.5-alpha.2+, `conversation` on alpha.1) + and skips the transparent display:contents slot hosts above it. The rule used to anchor on a structural has()-selector over the conversation slot wrapper, whose match cache invalidates on every #root subtree mutation (the streaming chat mutates it constantly), re-evaluating the selector @@ -73,13 +51,7 @@ body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > hea padding-right: 78px; } -body[data-dsh-sidebar-dragging] #root [data-dsh-frame], -body[data-dsh-sidebar-dragging] #root > [data-slot="root"] > div, -body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-side="details"], -body[data-dsh-sidebar-dragging] #root > [data-slot="root"] > div > [data-side="details"], -body[data-dsh-sidebar-dragging] #root [data-dsh-center-col], -#root [data-dsh-frame][data-dragging] > [data-side="details"], -#root > [data-slot="root"] > div[data-dragging] > [data-side="details"] { +body[data-dsh-sidebar-dragging] #root [data-dsh-center-col] { transition: none; } @@ -103,10 +75,6 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-center-col], } @media (prefers-reduced-motion: reduce) { - #root [data-dsh-frame], - #root > [data-slot="root"] > div, - #root [data-dsh-frame] > [data-side="details"], - #root > [data-slot="root"] > div > [data-side="details"], #root [data-dsh-center-col] { transition: none; } @@ -117,10 +85,10 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-center-col], * The ui-primitives Menu clamps its main portal list into the viewport but * positions nested submenus with static CSS only — anchored at the parent * row's bottom, growing UP and to the RIGHT — with no clamp whatsoever. - * Docked on the window's right edge, both defaults are the worst case for - * this panel: a tall "open with" submenu escapes past the viewport top when - * the row menu opens near the top, and past the right edge on narrow panels - * (PANEL_MIN 280 < card 218 + gap + submenu 165). + * Both defaults are the worst case for our menus: a tall "open with" submenu + * escapes past the viewport top when the row menu opens near the top, and + * past the right edge in a narrow workbench pane (a pane can be far narrower + * than the card 218 + gap + submenu 165 a submenu needs). * * menu-flip.ts publishes `data-dsh-sidebar-submenu` on — with the * words "down" and/or "left" — for exactly the lifetime of one of OUR diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts index de1d0f4..45dc7bb 100644 --- a/src/client/locales-ar.ts +++ b/src/client/locales-ar.ts @@ -18,6 +18,12 @@ /** The ar dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const ar: Record = { files: 'الملفات', + guideDescFiles: 'استعرض شجرة ملفات مساحة العمل؛ انقر للمعاينة أو التحرير', + guideDescGit: 'تغييرات Git وتعديلات ملفات هذه الجلسة في تبويب واحد', + guideDescSubagent: 'بنية الوكلاء الفرعيين والمهام الخلفية مباشرةً', + guideDescSidechat: 'خيط متابعة مستقل يرث سياق هذه الجلسة', + guideDescTerminal: 'صدفة حقيقية (xterm + pty) مع إعادة الاتصال', + guideDescBrowser: 'متصفح مدمج يمكنه فتح روابط المحادثة', changesSessionEmpty: 'لا توجد عمليات ملفات في هذه الجلسة بعد', changesRead: 'قراءة', changesMdReading: 'قراءة', @@ -82,7 +88,6 @@ export const ar: Record = { closeOtherTabs: 'إغلاق التبويبات الأخرى', closeLeftTabs: 'إغلاق التبويبات على اليسار', closeRightTabs: 'إغلاق التبويبات على اليمين', - moveToFreeWindow: 'النقل إلى نافذة حرة', floatDropHint: 'حرّك للفتح في نافذة حرة', dockToSidebar: 'العودة إلى الشريط الجانبي', pinTerminal: 'تثبيت الطرفية', @@ -97,8 +102,6 @@ export const ar: Record = { pinnedTerminalScopeGlobal: 'مثبتة عام', pinnedRailLabel: 'الطرفيات المثبتة', closePinnedTerminal: 'إغلاق الطرفية', - collapse: 'طي الشريط الجانبي', - expand: 'توسيع الشريط الجانبي', collapseBottomPanel: 'طي اللوحة السفلية', expandBottomPanel: 'توسيع اللوحة السفلية', terminalError: 'فشل اتصال الطرفية', @@ -156,12 +159,6 @@ export const ar: Record = { changesOpenDiffTab: 'فتح في تبويب diff منفصل', changesClosePreview: 'إغلاق المعاينة', changesResizePreview: 'تغيير حجم المعاينة', - changesDiffOpenTitle: 'يفتح الفرق كـ', - changesDiffOpenDesc: 'أين يضع إجراء «التوسيع إلى تبويب diff» الفرق', - changesDiffOpenFloat: 'نافذة عائمة', - changesDiffOpenFloatDesc: 'نافذة عائمة في وسط الشاشة — سحب وتحجيم وتثبيت فوق', - changesDiffOpenPane: 'لوحة', - changesDiffOpenPaneDesc: 'مثبتة أسفل اللوحة المصدر (تقسيم diff بنمط VSCode)', changesLoadError: 'سجلات ملفات الجلسة غير متاحة الآن', staged: 'مُدرج', unstaged: 'غير مُدرج', @@ -235,13 +232,6 @@ export const ar: Record = { settingsIntro: 'إدارة ما تعرضه البطاقة الجانبية وكيف تتصرف', settingsPopupDesc: 'تكوين خيارات ذات صلة بـ {feature}', settingsDone: 'تم', - settingsOpenTitle: 'فتح افتراضياً للمحادثات الجديدة', - settingsOpenDesc: 'توسيع البطاقة الجانبية تلقائياً للمحادثات الجديدة تماماً؛ المحادثات الموجودة تحتفظ بتخطيطاتها', - settingsWidthTitle: 'الحصة الافتراضية للعرض', - settingsWidthDesc: 'الحصة الافتراضية للبطاقة الجانبية من عرض النافذة للمحادثات الجديدة (20–60)', - settingsWidthSuffix: '%', - settingsOpenPathTitle: 'فتح ملفات المحادثة في الشريط الجانبي', - settingsOpenPathDesc: 'فتح روابط الملفات في المحادثة (صفوف الأدوات، الملفات الناتجة، الإشارات) في محرّر الشريط الجانبي بدلاً من التطبيق الافتراضي للنظام', settingsOpenToolsTitle: 'حقن أداة فتح الشريط الجانبي للنموذج', settingsOpenToolsDesc: 'عند التفعيل، يمكن للنموذج فتح الملفات والمجلدات وصفحات HTTP(S) في الشريط الجانبي عبر أداة sidebar_open (معطّل افتراضياً)', settingsTitleBarTitle: 'وضع توافق الموضع', diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts index 2b5b2a9..acc8364 100644 --- a/src/client/locales-de.ts +++ b/src/client/locales-de.ts @@ -3,6 +3,12 @@ */ export const de: Record = { files: 'Dateien', + guideDescFiles: 'Dateibaum des Arbeitsbereichs durchsuchen; Klick öffnet Vorschau oder Editor', + guideDescGit: 'Git-Änderungen und Dateiänderungen dieser Sitzung in einem Tab', + guideDescSubagent: 'Subagent-Topologie und Hintergrundaufgaben, live', + guideDescSidechat: 'Ein unabhängiger Folge-Thread mit dem Kontext dieser Sitzung', + guideDescTerminal: 'Eine echte Shell (xterm + pty) mit Wiederverbindung', + guideDescBrowser: 'Eingebetteter Browser, der Chat-Links übernehmen kann', changesSessionEmpty: 'Noch keine Dateioperationen in dieser Sitzung', changesRead: 'Lesen', changesMdReading: 'Lesen', @@ -67,7 +73,6 @@ export const de: Record = { closeOtherTabs: 'Andere Tabs schließen', closeLeftTabs: 'Tabs links schließen', closeRightTabs: 'Tabs rechts schließen', - moveToFreeWindow: 'In freies Fenster verschieben', floatDropHint: 'Loslassen, um in einem freien Fenster zu öffnen', dockToSidebar: 'Zurück zur Seitenleiste', pinTerminal: 'Terminal anheften', @@ -82,8 +87,6 @@ export const de: Record = { pinnedTerminalScopeGlobal: 'Global angeheftet', pinnedRailLabel: 'Angeheftete Terminals', closePinnedTerminal: 'Terminal schließen', - collapse: 'Seitenleiste einklappen', - expand: 'Seitenleiste ausklappen', collapseBottomPanel: 'Unteres Panel einklappen', expandBottomPanel: 'Unteres Panel ausklappen', terminalError: 'Terminalverbindung fehlgeschlagen', @@ -141,12 +144,6 @@ export const de: Record = { changesOpenDiffTab: 'In eigenem Tab öffnen', changesClosePreview: 'Vorschau schließen', changesResizePreview: 'Vorschauhöhe anpassen', - changesDiffOpenTitle: 'Diff öffnet als', - changesDiffOpenDesc: 'Wo die Aktion „In eigenem Tab öffnen“ den Diff platziert', - changesDiffOpenFloat: 'Freies Fenster', - changesDiffOpenFloatDesc: 'Ein schwebendes Fenster mittig im Viewport — verschieben, skalieren, oben halten', - changesDiffOpenPane: 'Bereich', - changesDiffOpenPaneDesc: 'Unter dem Quellbereich angedockt (VSCode-artiger Diff-Split)', changesLoadError: 'Sitzungsdatei-Aufzeichnungen sind derzeit nicht verfügbar', staged: 'Gestaged', unstaged: 'Nicht gestaged', @@ -220,13 +217,6 @@ export const de: Record = { settingsIntro: 'Verwalten Sie, was die Seitenkarte anzeigt und wie sie sich verhält', settingsPopupDesc: 'Optionen für „{feature}“ konfigurieren', settingsDone: 'Fertig', - settingsOpenTitle: 'Für neue Unterhaltungen standardmäßig geöffnet', - settingsOpenDesc: 'Die Seitenkarte wird für brandneue Unterhaltungen automatisch ausgeklappt; bestehende Unterhaltungen behalten ihr eigenes Layout', - settingsWidthTitle: 'Standardbreite', - settingsWidthDesc: 'Standardanteil der Seitenkarte an der Fensterbreite für neue Unterhaltungen (20–60)', - settingsWidthSuffix: '%', - settingsOpenPathTitle: 'Chat-Dateien in der Seitenleiste öffnen', - settingsOpenPathDesc: 'Dateilinks im Chat (Werkzeugzeilen, erstellte Dateien, Erwähnungen) werden im Seitenleisten-Editor geöffnet statt in der System-Standardanwendung', settingsOpenToolsTitle: 'Seitenleisten-Öffnungswerkzeug für das Modell bereitstellen', settingsOpenToolsDesc: 'Wenn aktiviert, kann das Modell über das sidebar_open-Werkzeug Dateien, Ordner und HTTP(S)-Seiten in der Seitenleiste öffnen (standardmäßig deaktiviert)', settingsTitleBarTitle: 'Kompatibilitätsmodus der Position', diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts index f1f240c..0425d45 100644 --- a/src/client/locales-fr.ts +++ b/src/client/locales-fr.ts @@ -10,6 +10,12 @@ /** fr dictionary for the `betterSidebar` namespace. */ export const fr: Record = { files: 'Fichiers', + guideDescFiles: 'Parcourir l\'arborescence du workspace ; un clic ouvre l\'aperçu ou l\'édition', + guideDescGit: 'Modifications Git et éditions de cette session dans un seul onglet', + guideDescSubagent: 'Topologie des sous-agents et tâches d\'arrière-plan, en direct', + guideDescSidechat: 'Un fil de suivi indépendant héritant du contexte de cette session', + guideDescTerminal: 'Un vrai shell (xterm + pty) avec reconnexion', + guideDescBrowser: 'Navigateur intégré, qui peut reprendre les liens du chat', changesSessionEmpty: 'Aucune opération de fichier dans cette session', changesRead: 'Lecture', changesMdReading: 'Lecture', @@ -74,7 +80,6 @@ export const fr: Record = { closeOtherTabs: 'Fermer les autres onglets', closeLeftTabs: 'Fermer les onglets à gauche', closeRightTabs: 'Fermer les onglets à droite', - moveToFreeWindow: 'Déplacer vers la fenêtre flottante', floatDropHint: 'Relâchez pour ouvrir dans une fenêtre flottante', dockToSidebar: 'Revenir à la barre latérale', pinTerminal: 'Épingler le terminal', @@ -89,8 +94,6 @@ export const fr: Record = { pinnedTerminalScopeGlobal: 'Épinglé globalement', pinnedRailLabel: 'Terminaux épinglés', closePinnedTerminal: 'Fermer le terminal', - collapse: 'Réduire la barre latérale', - expand: 'Déployer la barre latérale', collapseBottomPanel: 'Réduire le panneau inférieur', expandBottomPanel: 'Déployer le panneau inférieur', terminalError: 'Échec de la connexion au terminal', @@ -148,12 +151,6 @@ export const fr: Record = { changesOpenDiffTab: 'Ouvrir dans un onglet diff', changesClosePreview: 'Fermer l\'aperçu', changesResizePreview: 'Redimensionner l\'aperçu', - changesDiffOpenTitle: 'Le diff s\'ouvre en', - changesDiffOpenDesc: 'Où l\'action « Ouvrir dans un onglet diff » place le diff', - changesDiffOpenFloat: 'Fenêtre flottante', - changesDiffOpenFloatDesc: 'Une fenêtre flottante centrée sur la fenêtre — déplacer, redimensionner, garder au premier plan', - changesDiffOpenPane: 'Panneau', - changesDiffOpenPaneDesc: 'Ancré sous le panneau source (division de diff style VSCode)', changesLoadError: 'Les enregistrements de fichiers de la session sont indisponibles', staged: 'En scène', unstaged: 'Hors scène', @@ -227,13 +224,6 @@ export const fr: Record = { settingsIntro: 'Gérer le contenu affiché par la carte latérale et son comportement par défaut', settingsPopupDesc: 'Configurer les options liées à « {feature} »', settingsDone: 'Terminé', - settingsOpenTitle: 'Ouvert par défaut pour les nouvelles sessions', - settingsOpenDesc: 'Déployer automatiquement la carte latérale lors de la création d’une session ; les sessions existantes conservent chacune leur disposition', - settingsWidthTitle: 'Part de largeur par défaut', - settingsWidthDesc: 'Pourcentage de la largeur de la fenêtre occupé par la carte latérale pour les nouvelles sessions (20–60)', - settingsWidthSuffix: '%', - settingsOpenPathTitle: 'Ouvrir les fichiers de discussion dans la barre latérale', - settingsOpenPathDesc: 'À la place de l’application par défaut du système, ouvrir dans l’éditeur de la barre latérale les liens vers les fichiers du chat (lignes d’outils, listes de produits, mentions de fichiers)', settingsOpenToolsTitle: 'Injecter l\'outil d\'ouverture latérale pour le modèle', settingsOpenToolsDesc: 'Une fois activé, le modèle peut ouvrir des fichiers, dossiers et pages HTTP(S) dans la barre latérale via l\'outil sidebar_open (désactivé par défaut)', settingsTitleBarTitle: 'Mode de compatibilité de position', diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts index 1710368..63b095b 100644 --- a/src/client/locales-hi.ts +++ b/src/client/locales-hi.ts @@ -17,6 +17,12 @@ /** The hi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const hi: Record = { files: 'फ़ाइलें', + guideDescFiles: 'वर्कस्पेस फ़ाइल ट्री देखें; क्लिक करने पर प्रीव्यू या संपादन खुलता है', + guideDescGit: 'Git बदलाव और इस सत्र के फ़ाइल बदलाव एक ही टैब में', + guideDescSubagent: 'सबएजेंट टोपोलॉजी और बैकग्राउंड कार्य, लाइव', + guideDescSidechat: 'इस सत्र का संदर्भ विरासत में लेने वाला स्वतंत्र फ़ॉलो-अप थ्रेड', + guideDescTerminal: 'असली शेल (xterm + pty), रीकनेक्ट सहित', + guideDescBrowser: 'अंतर्निहित ब्राउज़र, चैट के लिंक भी खोल सकता है', changesSessionEmpty: 'इस सत्र में अभी कोई फ़ाइल ऑपरेशन नहीं', changesRead: 'पढ़ना', changesMdReading: 'पठन', @@ -81,7 +87,6 @@ export const hi: Record = { closeOtherTabs: 'अन्य टैब बंद करें', closeLeftTabs: 'बाएँ टैब बंद करें', closeRightTabs: 'दाएँ टैब बंद करें', - moveToFreeWindow: 'फ़्री विंडो में ले जाएँ', floatDropHint: 'फ़्री विंडो में खोलने के लिए छोड़ें', dockToSidebar: 'साइडबार पर वापस जाएँ', pinTerminal: 'टर्मिनल पिन करें', @@ -96,8 +101,6 @@ export const hi: Record = { pinnedTerminalScopeGlobal: 'वैश्विक रूप से पिन किया गया', pinnedRailLabel: 'पिन किए गए टर्मिनल', closePinnedTerminal: 'टर्मिनल बंद करें', - collapse: 'साइडबार संक्षिप्त करें', - expand: 'साइडबार विस्तृत करें', collapseBottomPanel: 'निचला पैनल संक्षिप्त करें', expandBottomPanel: 'निचला पैनल विस्तृत करें', terminalError: 'टर्मिनल कनेक्शन विफल', @@ -155,12 +158,6 @@ export const hi: Record = { changesOpenDiffTab: 'अलग diff टैब में खोलें', changesClosePreview: 'पूर्वावलोकन बंद करें', changesResizePreview: 'पूर्वावलोकन का आकार बदलें', - changesDiffOpenTitle: 'diff खुलता है', - changesDiffOpenDesc: '«अलग diff टैब में खोलें» क्रिया diff को कहाँ रखती है', - changesDiffOpenFloat: 'मुक्त विंडो', - changesDiffOpenFloatDesc: 'व्यूपोर्ट के केंद्र में एक तैरती विंडो — खींचें, आकार बदलें, ऊपर रखें', - changesDiffOpenPane: 'पैनल', - changesDiffOpenPaneDesc: 'सोर्स पैनल के नीचे डॉक किया (VSCode-शैली diff स्प्लिट)', changesLoadError: 'सेशन फ़ाइल रिकॉर्ड अभी अनुपलब्ध हैं', staged: 'स्टेज किया', unstaged: 'अनस्टेज', @@ -234,13 +231,6 @@ export const hi: Record = { settingsIntro: 'साइड कार्ड क्या दिखाता है और कैसे व्यवहार करता है प्रबंधित करें', settingsPopupDesc: '{feature} के लिए संबंधित विकल्प कॉन्फ़िगर करें', settingsDone: 'पूर्ण', - settingsOpenTitle: 'नए वार्तालाप के लिए डिफ़ॉल्ट रूप से खोलें', - settingsOpenDesc: 'बिल्कुल नए वार्तालाप के लिए साइड कार्ड स्वतः विस्तृत करें; मौजूदा वार्तालाप अपना लेआउट रखते हैं', - settingsWidthTitle: 'डिफ़ॉल्ट चौड़ाई हिस्सा', - settingsWidthDesc: 'नए वार्तालाप के लिए साइड कार्ड की विंडो चौड़ाई का डिफ़ॉल्ट हिस्सा (20–60)', - settingsWidthSuffix: '%', - settingsOpenPathTitle: 'चैट फ़ाइलें साइडबार में खोलें', - settingsOpenPathDesc: 'चैट में फ़ाइल लिंक (टूल पंक्ति, उत्पादित फ़ाइलें, उल्लेख) क्लिक करने पर सिस्टम डिफ़ॉल्ट ऐप के बजाय साइडबार एडिटर में खोलें', settingsOpenToolsTitle: 'मॉडल के लिए साइडबार ओपन टूल इंजेक्ट करें', settingsOpenToolsDesc: 'चालू होने पर, मॉडल sidebar_open टूल से साइडबार में फ़ाइलें, फ़ोल्डर और HTTP(S) पेज खोल सकता है (डिफ़ॉल्ट रूप से बंद)', settingsTitleBarTitle: 'स्थिति संगतता मोड', diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts index d61b886..7babc43 100644 --- a/src/client/locales-id.ts +++ b/src/client/locales-id.ts @@ -15,6 +15,12 @@ export const id: Record = { files: 'Berkas', + guideDescFiles: 'Telusuri pohon berkas workspace; klik untuk pratinjau atau edit', + guideDescGit: 'Perubahan Git dan perubahan berkas sesi ini dalam satu tab', + guideDescSubagent: 'Topologi subagent dan tugas latar belakang, langsung', + guideDescSidechat: 'Utas lanjutan independen yang mewarisi konteks sesi ini', + guideDescTerminal: 'Shell sungguhan (xterm + pty), bisa menyambung ulang', + guideDescBrowser: 'Peramban tertanam, bisa mengambil alih tautan chat', changesSessionEmpty: 'Belum ada operasi file di sesi ini', changesRead: 'Baca', changesMdReading: 'Baca', @@ -79,7 +85,6 @@ export const id: Record = { closeOtherTabs: 'Tutup Tab Lain', closeLeftTabs: 'Tutup Tab di Kiri', closeRightTabs: 'Tutup Tab di Kanan', - moveToFreeWindow: 'Pindahkan ke jendela bebas', floatDropHint: 'Lepaskan untuk membuka di jendela bebas', dockToSidebar: 'Kembali ke bilah samping', pinTerminal: 'Sematkan Terminal', @@ -94,8 +99,6 @@ export const id: Record = { pinnedTerminalScopeGlobal: 'Disematkan global', pinnedRailLabel: 'Terminal Tersemat', closePinnedTerminal: 'Tutup Terminal', - collapse: 'Lipat sidebar', - expand: 'Bentangkan sidebar', collapseBottomPanel: 'Lipat panel bawah', expandBottomPanel: 'Bentangkan panel bawah', terminalError: 'Koneksi terminal gagal', @@ -153,12 +156,6 @@ export const id: Record = { changesOpenDiffTab: 'Buka di tab diff terpisah', changesClosePreview: 'Tutup pratinjau', changesResizePreview: 'Ubah ukuran pratinjau', - changesDiffOpenTitle: 'Diff terbuka sebagai', - changesDiffOpenDesc: 'Di mana aksi "buka di tab diff terpisah" menempatkan diff', - changesDiffOpenFloat: 'Jendela bebas', - changesDiffOpenFloatDesc: 'Jendela mengambang di tengah viewport — geser, ubah ukuran, selalu di atas', - changesDiffOpenPane: 'Panel', - changesDiffOpenPaneDesc: 'Ditambat di bawah panel sumber (split diff gaya VSCode)', changesLoadError: 'Catatan berkas sesi sedang tidak tersedia', staged: 'Staged', unstaged: 'Unstaged', @@ -232,13 +229,6 @@ export const id: Record = { settingsIntro: 'Kelola apa yang ditampilkan kartu samping dan perilakunya', settingsPopupDesc: 'Konfigurasikan opsi terkait untuk {feature}', settingsDone: 'Selesai', - settingsOpenTitle: 'Buka secara default untuk obrolan baru', - settingsOpenDesc: 'Bentangkan kartu samping secara otomatis untuk obrolan baru; obrolan yang sudah ada mempertahankan tata letaknya', - settingsWidthTitle: 'Porsi lebar default', - settingsWidthDesc: 'Porsi lebar jendela default kartu samping untuk obrolan baru (20–60)', - settingsWidthSuffix: '%', - settingsOpenPathTitle: 'Buka berkas obrolan di sidebar', - settingsOpenPathDesc: 'Buka tautan berkas di obrolan (baris alat, berkas yang dihasilkan, mention) di editor sidebar alih-alih aplikasi default sistem', settingsOpenToolsTitle: 'Suntik alat buka sidebar untuk model', settingsOpenToolsDesc: 'Saat diaktifkan, model dapat membuka file, folder, dan halaman HTTP(S) di sidebar melalui alat sidebar_open (nonaktif secara default)', settingsTitleBarTitle: 'Mode kompatibilitas posisi', diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts index 8635e2d..c1a92f6 100644 --- a/src/client/locales-it.ts +++ b/src/client/locales-it.ts @@ -8,6 +8,12 @@ export const it: Record = { files: 'File', + guideDescFiles: 'Sfoglia l\'albero del workspace; un clic apre anteprima o editor', + guideDescGit: 'Modifiche Git e modifiche ai file di questa sessione in un solo tab', + guideDescSubagent: 'Topologia dei subagent e attività in background, in tempo reale', + guideDescSidechat: 'Un thread di approfondimento indipendente che eredita il contesto della sessione', + guideDescTerminal: 'Una vera shell (xterm + pty) con riconnessione', + guideDescBrowser: 'Browser integrato, può aprire i link della chat', changesSessionEmpty: 'Ancora nessuna operazione sui file in questa sessione', changesRead: 'Lettura', changesMdReading: 'Lettura', @@ -72,7 +78,6 @@ export const it: Record = { closeOtherTabs: 'Chiudi le altre schede', closeLeftTabs: 'Chiudi le schede a sinistra', closeRightTabs: 'Chiudi le schede a destra', - moveToFreeWindow: 'Sposta in finestra libera', floatDropHint: 'Rilascia per aprire in una finestra libera', dockToSidebar: 'Torna alla barra laterale', pinTerminal: 'Blocca Terminale', @@ -87,8 +92,6 @@ export const it: Record = { pinnedTerminalScopeGlobal: 'Bloccato globalmente', pinnedRailLabel: 'Terminali Bloccati', closePinnedTerminal: 'Chiudi Terminale', - collapse: 'Comprimi barra laterale', - expand: 'Espandi barra laterale', collapseBottomPanel: 'Comprimi pannello inferiore', expandBottomPanel: 'Espandi pannello inferiore', terminalError: 'Connessione del terminale non riuscita', @@ -146,12 +149,6 @@ export const it: Record = { changesOpenDiffTab: 'Apri in una scheda diff', changesClosePreview: 'Ridimensiona l\'anteprima', changesResizePreview: 'Ridimensiona anteprima', - changesDiffOpenTitle: 'Il diff si apre come', - changesDiffOpenDesc: 'Dove l\'azione "apri in una scheda diff" colloca il diff', - changesDiffOpenFloat: 'Finestra libera', - changesDiffOpenFloatDesc: 'Una finestra flottante centrata sul viewport — trascina, ridimensiona, sempre in primo piano', - changesDiffOpenPane: 'Pannello', - changesDiffOpenPaneDesc: 'Ancorato sotto il pannello sorgente (divisione diff in stile VSCode)', changesLoadError: 'I record dei file della sessione non sono disponibili', staged: 'In stage', unstaged: 'Non in stage', @@ -225,13 +222,6 @@ export const it: Record = { settingsIntro: 'Gestisca cosa mostra la scheda laterale e come si comporta', settingsPopupDesc: 'Configuri le opzioni relative a {feature}', settingsDone: 'Fatto', - settingsOpenTitle: 'Apri per impostazione predefinita per le nuove conversazioni', - settingsOpenDesc: 'Espande automaticamente la scheda laterale per le conversazioni nuove; le conversazioni esistenti mantengono il proprio layout', - settingsWidthTitle: 'Quota di larghezza predefinita', - settingsWidthDesc: 'La quota predefinita della scheda laterale sulla larghezza della finestra per le nuove conversazioni (20–60)', - settingsWidthSuffix: '%', - settingsOpenPathTitle: 'Apri i file della chat nella barra laterale', - settingsOpenPathDesc: 'Apre i collegamenti ai file nella chat (righe di strumenti, file prodotti, menzioni) nell’editor della barra laterale invece dell’app predefinita di sistema', settingsOpenToolsTitle: 'Iniettare lo strumento di apertura della barra laterale per il modello', settingsOpenToolsDesc: 'Se attivato, il modello può aprire file, cartelle e pagine HTTP(S) nella barra laterale tramite lo strumento sidebar_open (disattivato per impostazione predefinita)', settingsTitleBarTitle: 'Modalità di compatibilità della posizione', diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts index a20f693..71483ff 100644 --- a/src/client/locales-ja.ts +++ b/src/client/locales-ja.ts @@ -17,6 +17,12 @@ /** The ja dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const ja: Record = { files: 'ファイル', + guideDescFiles: 'ワークスペースのファイルツリーを閲覧し、クリックでプレビュー/編集', + guideDescGit: 'Git の変更と今回のセッションのファイル変更を 1 つのタブに', + guideDescSubagent: 'サブエージェントの構成とバックグラウンドタスクをリアルタイム表示', + guideDescSidechat: '現在のセッションの文脈を引き継ぐ独立した追加スレッド', + guideDescTerminal: '本物のシェル(xterm + pty)、再接続対応', + guideDescBrowser: '埋め込みブラウザ。チャット内のリンクも引き受け可能', changesSessionEmpty: 'このセッションにはファイル操作がまだありません', changesRead: '読み取り', changesMdReading: '閲覧', @@ -81,7 +87,6 @@ export const ja: Record = { closeOtherTabs: '他のタブを閉じる', closeLeftTabs: '左のタブを閉じる', closeRightTabs: '右のタブを閉じる', - moveToFreeWindow: 'フリーウィンドウへ移動', floatDropHint: '離すとフリーウィンドウで開きます', dockToSidebar: 'サイドバーへ戻す', pinTerminal: 'ターミナルを固定', @@ -96,8 +101,6 @@ export const ja: Record = { pinnedTerminalScopeGlobal: 'グローバルに固定', pinnedRailLabel: '固定されたターミナル', closePinnedTerminal: 'ターミナルを閉じる', - collapse: 'サイドバーを折りたたむ', - expand: 'サイドバーを展開', collapseBottomPanel: '下パネルを折りたたむ', expandBottomPanel: '下パネルを展開', terminalError: 'ターミナル接続に失敗', @@ -155,12 +158,6 @@ export const ja: Record = { changesOpenDiffTab: '独立タブで開く', changesClosePreview: 'プレビューを閉じる', changesResizePreview: 'プレビューの高さを変更', - changesDiffOpenTitle: 'diff の展開先', - changesDiffOpenDesc: '「独立タブで開く」操作が diff をどこに配置するか', - changesDiffOpenFloat: 'フリーウィンドウ', - changesDiffOpenFloatDesc: 'ビューポート中央に浮かぶウィンドウ — ドラッグ・リサイズ・最前面固定', - changesDiffOpenPane: 'ペイン', - changesDiffOpenPaneDesc: 'ソースペインの下にドッキング(VSCode 式 diff 分割)', changesLoadError: 'セッションのファイル記録は現在利用できません', staged: 'ステージ済み', unstaged: '未ステージ', @@ -234,13 +231,6 @@ export const ja: Record = { settingsIntro: 'サイドカードの表示内容とデフォルト挙動を管理', settingsPopupDesc: '「{feature}」の関連オプションを設定', settingsDone: '完了', - settingsOpenTitle: '新規会話でデフォルト展開', - settingsOpenDesc: '新規会話時にサイドカードを自動展開。既存の会話はそれぞれのレイアウトを維持', - settingsWidthTitle: 'デフォルト幅の比率', - settingsWidthDesc: '新規会話時のサイドカードがウィンドウ幅に占める割合 (20–60)', - settingsWidthSuffix: '%', - settingsOpenPathTitle: 'チャット内ファイルをサイドバーで開く', - settingsOpenPathDesc: 'チャット内のファイルリンク(ツール行、産物リスト、ファイル言及)クリック時に、システム既定アプリではなくサイドバーエディターで開く', settingsOpenToolsTitle: 'モデルにサイドバー開くツールを注入', settingsOpenToolsDesc: 'オンにすると、モデルは sidebar_open ツールでサイドバーにファイル・フォルダー・HTTP(S) ページを開ける(デフォルトオフ)', settingsTitleBarTitle: '位置互換モード', diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts index a0907be..658c390 100644 --- a/src/client/locales-ko.ts +++ b/src/client/locales-ko.ts @@ -9,6 +9,12 @@ export const ko: Record = { files: '파일', + guideDescFiles: '워크스페이스 파일 트리를 보고 클릭하면 미리보기/편집', + guideDescGit: 'Git 변경 사항과 이번 세션의 파일 변경을 한 탭에서', + guideDescSubagent: '하위 에이전트 구조와 백그라운드 작업을 실시간으로', + guideDescSidechat: '현재 세션의 컨텍스트를 이어받는 독립 후속 스레드', + guideDescTerminal: '실제 셸(xterm + pty), 재연결 지원', + guideDescBrowser: '내장 브라우저, 채팅 링크도 대신 열 수 있음', changesSessionEmpty: '이 세션에 아직 파일 작업이 없습니다', changesRead: '읽기', changesMdReading: '읽기', @@ -73,7 +79,6 @@ export const ko: Record = { closeOtherTabs: '다른 탭 닫기', closeLeftTabs: '왼쪽 탭 닫기', closeRightTabs: '오른쪽 탭 닫기', - moveToFreeWindow: '자유 창으로 이동', floatDropHint: '놓으면 자유 창에서 열립니다', dockToSidebar: '사이드바로 돌아가기', pinTerminal: '터미널 고정', @@ -88,8 +93,6 @@ export const ko: Record = { pinnedTerminalScopeGlobal: '전역에 고정됨', pinnedRailLabel: '고정된 터미널', closePinnedTerminal: '터미널 닫기', - collapse: '사이드바 접기', - expand: '사이드바 펼치기', collapseBottomPanel: '하단 패널 접기', expandBottomPanel: '하단 패널 펼치기', terminalError: '터미널 연결 실패', @@ -147,12 +150,6 @@ export const ko: Record = { changesOpenDiffTab: '별도 탭에서 열기', changesClosePreview: '미리보기 닫기', changesResizePreview: '미리보기 높이 조정', - changesDiffOpenTitle: 'diff 열기 위치', - changesDiffOpenDesc: '「별도 탭에서 열기」 작업이 diff를 배치하는 곳', - changesDiffOpenFloat: '자유 창', - changesDiffOpenFloatDesc: '뷰포트 중앙의 떠 있는 창 — 드래그, 크기 조절, 항상 위', - changesDiffOpenPane: '패널', - changesDiffOpenPaneDesc: '소스 패널 아래에 도킹(VSCode 스타일 diff 분할)', changesLoadError: '세션 파일 기록을 지금 사용할 수 없습니다', staged: '스테이징됨', unstaged: '스테이징 안 됨', @@ -226,13 +223,6 @@ export const ko: Record = { settingsIntro: '사이드 카드의 표시 내용과 기본 동작을 관리합니다', settingsPopupDesc: '"{feature}" 관련 옵션을 구성합니다', settingsDone: '완료', - settingsOpenTitle: '새 대화 기본 열림', - settingsOpenDesc: '새 대화를 만들 때 사이드 카드를 자동으로 펼칩니다. 기존 대화는 각자의 레이아웃을 유지합니다', - settingsWidthTitle: '기본 너비 비율', - settingsWidthDesc: '새 대화에서 사이드 카드가 창 너비에서 차지하는 비율(%) (20–60)', - settingsWidthSuffix: '%', - settingsOpenPathTitle: '채팅 영역 파일을 사이드바에서 열기', - settingsOpenPathDesc: '채팅에서 파일 링크(도구 행, 산출물 목록, 파일 언급)를 클릭하면 사이드바 편집기에서 열고, 시스템 기본 앱은 호출하지 않습니다', settingsOpenToolsTitle: '모델에 사이드바 열기 도구 주입', settingsOpenToolsDesc: '켜면 모델이 sidebar_open 도구로 사이드바에서 파일·폴더·HTTP(S) 페이지를 열 수 있습니다(기본 꺼짐)', settingsTitleBarTitle: '위치 호환 모드', diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts index 736fac0..043a105 100644 --- a/src/client/locales-nl.ts +++ b/src/client/locales-nl.ts @@ -15,6 +15,12 @@ */ export const nl: Record = { files: 'Bestanden', + guideDescFiles: 'Blader door de bestandsboom van de workspace; klik opent voorbeeld of editor', + guideDescGit: 'Git-wijzigingen en bestandswijzigingen van deze sessie in één tabblad', + guideDescSubagent: 'Subagent-topologie en achtergrondtaken, live', + guideDescSidechat: 'Een onafhankelijke vervolgthread met de context van deze sessie', + guideDescTerminal: 'Een echte shell (xterm + pty) met herverbinding', + guideDescBrowser: 'Ingebouwde browser die chatlinks kan overnemen', changesSessionEmpty: 'Nog geen bestandsbewerkingen in deze sessie', changesRead: 'Lezen', changesMdReading: 'Lezen', @@ -79,7 +85,6 @@ export const nl: Record = { closeOtherTabs: 'Andere tabbladen sluiten', closeLeftTabs: 'Tabbladen links sluiten', closeRightTabs: 'Tabbladen rechts sluiten', - moveToFreeWindow: 'Naar vrij venster verplaatsen', floatDropHint: 'Loslaten om in een vrij venster te openen', dockToSidebar: 'Terug naar de zijbalk', pinTerminal: 'Terminal vastmaken', @@ -94,8 +99,6 @@ export const nl: Record = { pinnedTerminalScopeGlobal: 'Wereldwijd vastgemaakt', pinnedRailLabel: 'Vastgemaakte terminals', closePinnedTerminal: 'Terminal sluiten', - collapse: 'Zijbalk inklappen', - expand: 'Zijbalk uitklappen', collapseBottomPanel: 'Onderpaneel inklappen', expandBottomPanel: 'Onderpaneel uitklappen', terminalError: 'Terminalverbinding mislukt', @@ -153,12 +156,6 @@ export const nl: Record = { changesOpenDiffTab: 'In eigen diff-tabblad openen', changesClosePreview: 'Voorbeeld sluiten', changesResizePreview: 'Voorbeeld vergroten/verkleinen', - changesDiffOpenTitle: 'Diff opent als', - changesDiffOpenDesc: 'Waar de actie „in eigen diff-tabblad openen” de diff plaatst', - changesDiffOpenFloat: 'Vrij venster', - changesDiffOpenFloatDesc: 'Een zwevend venster in het midden — slepen, schalen, bovenop houden', - changesDiffOpenPane: 'Paneel', - changesDiffOpenPaneDesc: 'Vastgemaakt onder het bronpaneel (VSCode-achtige diff-split)', changesLoadError: 'Sessiebestandsregistraties zijn nu niet beschikbaar', staged: 'Gestaged', unstaged: 'Niet gestaged', @@ -232,13 +229,6 @@ export const nl: Record = { settingsIntro: 'Beheer wat de zijkaart toont en hoe deze zich gedraagt', settingsPopupDesc: 'Gerelateerde opties voor {feature} configureren', settingsDone: 'Klaar', - settingsOpenTitle: 'Standaard openen voor nieuwe conversaties', - settingsOpenDesc: 'De zijkaart automatisch uitklappen voor gloednieuwe conversaties; bestaande conversaties behouden hun eigen layout', - settingsWidthTitle: 'Standaard breedte-aandeel', - settingsWidthDesc: 'Het standaard breedte-aandeel van de zijkaart voor nieuwe conversaties (20–60)', - settingsWidthSuffix: '%', - settingsOpenPathTitle: 'Chatbestanden in de zijbalk openen', - settingsOpenPathDesc: 'Bestandslinks in de chat (toolrijen, geproduceerde bestanden, vermeldingen) openen in de zijbalk-editor in plaats van de standaard systeemapp', settingsOpenToolsTitle: 'Zijbalk-openen-tool voor het model injecteren', settingsOpenToolsDesc: 'Indien ingeschakeld kan het model bestanden, mappen en HTTP(S)-pagina\'s in de zijbalk openen via de sidebar_open-tool (standaard uit)', settingsTitleBarTitle: 'Positiecompatibiliteitsmodus', diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts index 4eef5eb..e09bab1 100644 --- a/src/client/locales-pl.ts +++ b/src/client/locales-pl.ts @@ -19,6 +19,12 @@ /** The pl dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const pl: Record = { files: 'Pliki', + guideDescFiles: 'Przeglądaj drzewo plików obszaru roboczego; klik otwiera podgląd lub edycję', + guideDescGit: 'Zmiany Git i zmiany plików z tej sesji w jednej karcie', + guideDescSubagent: 'Topologia subagentów i zadania w tle na żywo', + guideDescSidechat: 'Niezależny wątek uzupełniający dziedziczący kontekst tej sesji', + guideDescTerminal: 'Prawdziwy shell (xterm + pty) z ponownym łączeniem', + guideDescBrowser: 'Wbudowana przeglądarka, może przejmować linki z czatu', changesSessionEmpty: 'Brak operacji na plikach w tej sesji', changesRead: 'Odczyt', changesMdReading: 'Czytanie', @@ -83,7 +89,6 @@ export const pl: Record = { closeOtherTabs: 'Zamknij inne karty', closeLeftTabs: 'Zamknij karty po lewej', closeRightTabs: 'Zamknij karty po prawej', - moveToFreeWindow: 'Przenieś do wolnego okna', floatDropHint: 'Puść, aby otworzyć w wolnym oknie', dockToSidebar: 'Wróć do panelu bocznego', pinTerminal: 'Przypnij Terminal', @@ -98,8 +103,6 @@ export const pl: Record = { pinnedTerminalScopeGlobal: 'Przypięty globalnie', pinnedRailLabel: 'Przypięte terminale', closePinnedTerminal: 'Zamknij terminal', - collapse: 'Zwiń panel boczny', - expand: 'Rozwiń panel boczny', collapseBottomPanel: 'Zwiń panel dolny', expandBottomPanel: 'Rozwiń panel dolny', terminalError: 'Połączenie z terminalem nie powiodło się', @@ -157,12 +160,6 @@ export const pl: Record = { changesOpenDiffTab: 'Otwórz w osobnej karcie', changesClosePreview: 'Zamknij podgląd', changesResizePreview: 'Zmień wysokość podglądu', - changesDiffOpenTitle: 'Diff otwiera się jako', - changesDiffOpenDesc: 'Gdzie akcja „otwórz w osobnej karcie” umieszcza diff', - changesDiffOpenFloat: 'Odrębne okno', - changesDiffOpenFloatDesc: 'Pływające okno wyśrodkowane na ekranie — przeciągaj, skaluj, trzymaj na wierzchu', - changesDiffOpenPane: 'Panel', - changesDiffOpenPaneDesc: 'Zadokowane pod panelem źródłowym (podział diff w stylu VSCode)', changesLoadError: 'Zapisy plików sesji są teraz niedostępne', staged: 'W indeksie', unstaged: 'Poza indeksem', @@ -236,13 +233,6 @@ export const pl: Record = { settingsIntro: 'Zarządzaj tym, co pokazuje karta boczna i jak się zachowuje', settingsPopupDesc: 'Skonfiguruj opcje powiązane z: {feature}', settingsDone: 'Gotowe', - settingsOpenTitle: 'Otwórz domyślnie dla nowych rozmów', - settingsOpenDesc: 'Rozwiń kartę boczną automatycznie dla zupełnie nowych rozmów; istniejące rozmowy zachowują własne układy', - settingsWidthTitle: 'Domyślny udział szerokości', - settingsWidthDesc: 'Domyślny udział szerokości okna dla karty bocznej w nowych rozmowach (20–60)', - settingsWidthSuffix: '%', - settingsOpenPathTitle: 'Otwieraj pliki czatu w panelu bocznym', - settingsOpenPathDesc: 'Otwieraj linki plików na czacie (wiersze narzędzi, wyprodukowane pliki, wzmianki) w edytorze panelu bocznego zamiast w domyślnej aplikacji systemu', settingsOpenToolsTitle: 'Wstrzyknij narzędzie otwierania panelu bocznego dla modelu', settingsOpenToolsDesc: 'Po włączeniu model może otwierać pliki, foldery i strony HTTP(S) w panelu bocznym za pomocą narzędzia sidebar_open (domyślnie wyłączone)', settingsTitleBarTitle: 'Tryb zgodności pozycji', diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts index 7197677..ebdc6e7 100644 --- a/src/client/locales-pt.ts +++ b/src/client/locales-pt.ts @@ -1,5 +1,11 @@ export const pt: Record = { files: 'Arquivos', + guideDescFiles: 'Navegue pela árvore do workspace; um clique abre a pré-visualização ou a edição', + guideDescGit: 'Alterações do Git e edições desta sessão em um único separador', + guideDescSubagent: 'Topologia de subagentes e tarefas em segundo plano, ao vivo', + guideDescSidechat: 'Uma conversa paralela independente que herda o contexto desta sessão', + guideDescTerminal: 'Um shell real (xterm + pty) com reconexão', + guideDescBrowser: 'Navegador incorporado, que pode abrir links do chat', changesSessionEmpty: 'Ainda sem operações de arquivo nesta sessão', changesRead: 'Leitura', changesMdReading: 'Leitura', @@ -64,7 +70,6 @@ export const pt: Record = { closeOtherTabs: 'Fechar outras abas', closeLeftTabs: 'Fechar abas à esquerda', closeRightTabs: 'Fechar abas à direita', - moveToFreeWindow: 'Mover para janela flutuante', floatDropHint: 'Solte para abrir em uma janela flutuante', dockToSidebar: 'Voltar para a barra lateral', pinTerminal: 'Fixar Terminal', @@ -79,8 +84,6 @@ export const pt: Record = { pinnedTerminalScopeGlobal: 'Fixado globalmente', pinnedRailLabel: 'Terminais Fixados', closePinnedTerminal: 'Fechar Terminal', - collapse: 'Recolher barra lateral', - expand: 'Expandir barra lateral', collapseBottomPanel: 'Recolher painel inferior', expandBottomPanel: 'Expandir painel inferior', terminalError: 'Falha na conexão do terminal', @@ -138,12 +141,6 @@ export const pt: Record = { changesOpenDiffTab: 'Abrir em uma aba de diff', changesClosePreview: 'Fechar prévia', changesResizePreview: 'Ajustar altura da prévia', - changesDiffOpenTitle: 'O diff abre como', - changesDiffOpenDesc: 'Onde a ação "abrir em uma aba de diff" coloca o diff', - changesDiffOpenFloat: 'Janela livre', - changesDiffOpenFloatDesc: 'Uma janela flutuante centralizada — arraste, redimensione, mantenha no topo', - changesDiffOpenPane: 'Painel', - changesDiffOpenPaneDesc: 'Ancorado abaixo do painel de origem (divisão de diff estilo VSCode)', changesLoadError: 'Os registros de arquivos da sessão estão indisponíveis no momento', staged: 'Preparadas', unstaged: 'Não preparadas', @@ -217,13 +214,6 @@ export const pt: Record = { settingsIntro: 'Gerencie o que o cartão lateral mostra e como ele se comporta', settingsPopupDesc: 'Configurar opções relacionadas para {feature}', settingsDone: 'Concluído', - settingsOpenTitle: 'Abrir por padrão em novas conversas', - settingsOpenDesc: 'Expandir o cartão lateral automaticamente em conversas totalmente novas; conversas existentes mantêm seus próprios layouts', - settingsWidthTitle: 'Participação padrão na largura', - settingsWidthDesc: 'Participação padrão do cartão lateral na largura da janela em novas conversas (20–60)', - settingsWidthSuffix: '%', - settingsOpenPathTitle: 'Abrir arquivos do chat na barra lateral', - settingsOpenPathDesc: 'Abrir links de arquivos no chat (linhas de ferramentas, arquivos produzidos, menções) no editor da barra lateral em vez do aplicativo padrão do sistema', settingsOpenToolsTitle: 'Injetar a ferramenta de abertura na barra lateral para o modelo', settingsOpenToolsDesc: 'Quando ativado, o modelo pode abrir arquivos, pastas e páginas HTTP(S) na barra lateral pela ferramenta sidebar_open (desativado por padrão)', settingsTitleBarTitle: 'Modo de compatibilidade de posição', diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts index 86b3b7d..3fd5149 100644 --- a/src/client/locales-ru.ts +++ b/src/client/locales-ru.ts @@ -13,6 +13,12 @@ export const ru: Record = { files: 'Файлы', + guideDescFiles: 'Дерево файлов рабочей области: клик открывает просмотр или редактор', + guideDescGit: 'Изменения Git и правки файлов этой сессии в одной вкладке', + guideDescSubagent: 'Топология субагентов и фоновые задачи в реальном времени', + guideDescSidechat: 'Независимый дополнительный тред с контекстом этой сессии', + guideDescTerminal: 'Настоящий shell (xterm + pty) с переподключением', + guideDescBrowser: 'Встроенный браузер, перехватывает ссылки из чата', changesSessionEmpty: 'В этой сессии пока нет файловых операций', changesRead: 'Чтение', changesMdReading: 'Чтение', @@ -77,7 +83,6 @@ export const ru: Record = { closeOtherTabs: 'Закрыть остальные вкладки', closeLeftTabs: 'Закрыть вкладки слева', closeRightTabs: 'Закрыть вкладки справа', - moveToFreeWindow: 'Переместить в свободное окно', floatDropHint: 'Отпустите, чтобы открыть в свободном окне', dockToSidebar: 'Вернуть на боковую панель', pinTerminal: 'Закрепить терминал', @@ -92,8 +97,6 @@ export const ru: Record = { pinnedTerminalScopeGlobal: 'Закреплён глобально', pinnedRailLabel: 'Закреплённые терминалы', closePinnedTerminal: 'Закрыть терминал', - collapse: 'Свернуть боковую панель', - expand: 'Развернуть боковую панель', collapseBottomPanel: 'Свернуть нижнюю панель', expandBottomPanel: 'Развернуть нижнюю панель', terminalError: 'Сбой подключения терминала', @@ -151,12 +154,6 @@ export const ru: Record = { changesOpenDiffTab: 'Открыть в отдельной вкладке', changesClosePreview: 'Закрыть просмотр', changesResizePreview: 'Изменить высоту просмотра', - changesDiffOpenTitle: 'Diff открывается как', - changesDiffOpenDesc: 'Куда действие «открыть в отдельной вкладке» помещает diff', - changesDiffOpenFloat: 'Свободное окно', - changesDiffOpenFloatDesc: 'Плавающее окно по центру экрана — перетаскивание, масштаб, поверх всех', - changesDiffOpenPane: 'Панель', - changesDiffOpenPaneDesc: 'Прикреплено под исходной панелью (разделение diff в стиле VSCode)', changesLoadError: 'Записи файлов сессии сейчас недоступны', staged: 'В индексе', unstaged: 'Не в индексе', @@ -231,13 +228,6 @@ export const ru: Record = { settingsIntro: 'Управление содержимым и поведением боковой карточки по умолчанию', settingsPopupDesc: 'Настройка параметров «{feature}»', settingsDone: 'Готово', - settingsOpenTitle: 'Открывать по умолчанию для новых сессий', - settingsOpenDesc: 'Автоматически разворачивать боковую карточку для новых сессий; у существующих сессий сохраняется их собственная раскладка', - settingsWidthTitle: 'Доля ширины по умолчанию', - settingsWidthDesc: 'Доля боковой карточки в ширине окна для новых сессий, в процентах (20–60)', - settingsWidthSuffix: '%', - settingsOpenPathTitle: 'Открывать файлы чата в боковой панели', - settingsOpenPathDesc: 'При щелчке по ссылке на файл в чате (строки инструментов, списки результатов, упоминания) файл открывается в редакторе боковой панели, а не в системном приложении по умолчанию', settingsOpenToolsTitle: 'Внедрить инструмент открытия в боковой панели для модели', settingsOpenToolsDesc: 'Если включено, модель может открывать файлы, папки и HTTP(S)-страницы в боковой панели с помощью инструмента sidebar_open (по умолчанию выключено)', settingsTitleBarTitle: 'Режим совместимости заголовка', diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts index b8ad6a4..66d5edd 100644 --- a/src/client/locales-sv.ts +++ b/src/client/locales-sv.ts @@ -1,5 +1,11 @@ export const sv: Record = { files: 'Filer', + guideDescFiles: 'Bläddra i arbetsytans filträd; klick öppnar förhandsvisning eller redigering', + guideDescGit: 'Git-ändringar och sessionens filändringar i en flik', + guideDescSubagent: 'Subagenttopologi och bakgrundsjobb, live', + guideDescSidechat: 'En oberoende uppföljningstråd som ärver sessionens kontext', + guideDescTerminal: 'Ett riktigt skal (xterm + pty) med återanslutning', + guideDescBrowser: 'Inbäddad webbläsare som kan ta över chattlänkar', changesSessionEmpty: 'Inga filoperationer i den här sessionen ännu', changesRead: 'Läs', changesMdReading: 'Läsning', @@ -64,7 +70,6 @@ export const sv: Record = { closeOtherTabs: 'Stäng andra flikar', closeLeftTabs: 'Stäng flikar till vänster', closeRightTabs: 'Stäng flikar till höger', - moveToFreeWindow: 'Flytta till fritt fönster', floatDropHint: 'Släpp för att öppna i ett fritt fönster', dockToSidebar: 'Tillbaka till sidopanelen', pinTerminal: 'Fäst Terminal', @@ -79,8 +84,6 @@ export const sv: Record = { pinnedTerminalScopeGlobal: 'Fäst globalt', pinnedRailLabel: 'Fästa terminaler', closePinnedTerminal: 'Stäng terminal', - collapse: 'Komprimera sidopanel', - expand: 'Expandera sidopanel', collapseBottomPanel: 'Komprimera bottenpanel', expandBottomPanel: 'Expandera bottenpanel', terminalError: 'Terminalanslutning misslyckades', @@ -138,12 +141,6 @@ export const sv: Record = { changesOpenDiffTab: 'Öppna i egen diff-flik', changesClosePreview: 'Stäng förhandsvisning', changesResizePreview: 'Ändra storlek på förhandsvisningen', - changesDiffOpenTitle: 'Diff öppnas som', - changesDiffOpenDesc: 'Var åtgärden "öppna i egen diff-flik" placerar diffen', - changesDiffOpenFloat: 'Fritt fönster', - changesDiffOpenFloatDesc: 'Ett flytande fönster centrerat i vyn — dra, ändra storlek, håll överst', - changesDiffOpenPane: 'Panel', - changesDiffOpenPaneDesc: 'Dockad under källpanelen (VSCode-liknande diff-delning)', changesLoadError: 'Sessionens filposter är inte tillgängliga just nu', staged: 'Köade', unstaged: 'Avköade', @@ -217,13 +214,6 @@ export const sv: Record = { settingsIntro: 'Hantera vad sidokortet visar och hur det beter sig', settingsPopupDesc: 'Konfigurera relaterade alternativ för {feature}', settingsDone: 'Klar', - settingsOpenTitle: 'Öppna som standard för nya konversationer', - settingsOpenDesc: 'Expandera sidokortet automatiskt för helt nya konversationer; befintliga konversationer behåller sina egna layouter', - settingsWidthTitle: 'Standardbreddandel', - settingsWidthDesc: 'Sidokortets standardandel av fönsterbredden för nya konversationer (20–60)', - settingsWidthSuffix: '%', - settingsOpenPathTitle: 'Öppna chattfiler i sidopanelen', - settingsOpenPathDesc: 'Öppna fillänkar i chatten (verktygsrader, producerade filer, omnämnanden) i sidopanelens editor i stället för systemets standardapp', settingsOpenToolsTitle: 'Injicera sidopanel-öppningsverktyg för modellen', settingsOpenToolsDesc: 'När aktiverat kan modellen öppna filer, mappar och HTTP(S)-sidor i sidopanelen via sidebar_open-verktyget (av som standard)', settingsTitleBarTitle: 'Positions kompatibilitetsläge', diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts index 820baa8..72b31fd 100644 --- a/src/client/locales-th.ts +++ b/src/client/locales-th.ts @@ -17,6 +17,12 @@ /** The th dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const th: Record = { files: 'ไฟล์', + guideDescFiles: 'เรียกดูแผนผังไฟล์ของเวิร์กสเปซ คลิกเพื่อดูตัวอย่างหรือแก้ไข', + guideDescGit: 'การเปลี่ยนแปลง Git และไฟล์ที่แก้ในเซสชันนี้ในแท็บเดียว', + guideDescSubagent: 'โครงสร้างซับเอเจนต์และงานเบื้องหลังแบบเรียลไทม์', + guideDescSidechat: 'เธรดถามต่ออิสระที่สืบทอดบริบทของเซสชันนี้', + guideDescTerminal: 'เชลล์จริง (xterm + pty) เชื่อมต่อใหม่ได้', + guideDescBrowser: 'เบราว์เซอร์ในตัว รับช่วงลิงก์จากแชทได้', changesSessionEmpty: 'ยังไม่มีการดำเนินการไฟล์ในเซสชันนี้', changesRead: 'อ่าน', changesMdReading: 'อ่าน', @@ -81,7 +87,6 @@ export const th: Record = { closeOtherTabs: 'ปิด Tab อื่น', closeLeftTabs: 'ปิด Tab ด้านซ้าย', closeRightTabs: 'ปิด Tab ด้านขวา', - moveToFreeWindow: 'ย้ายไปยังหน้าต่างอิสระ', floatDropHint: 'ปล่อยเพื่อเปิดในหน้าต่างอิสระ', dockToSidebar: 'กลับไปที่แถบข้าง', pinTerminal: 'ปักหมุดเทอร์มินัล', @@ -96,8 +101,6 @@ export const th: Record = { pinnedTerminalScopeGlobal: 'ปักหมุดแบบ Global', pinnedRailLabel: 'เทอร์มินัลที่ปักหมุด', closePinnedTerminal: 'ปิดเทอร์มินัล', - collapse: 'ย่อแถบด้านข้าง', - expand: 'ขยายแถบด้านข้าง', collapseBottomPanel: 'ย่อพาเนลด้านล่าง', expandBottomPanel: 'ขยายพาเนลด้านล่าง', terminalError: 'การเชื่อมต่อเทอร์มินัลล้มเหลว', @@ -155,12 +158,6 @@ export const th: Record = { changesOpenDiffTab: 'เปิดในแท็บ diff แยก', changesClosePreview: 'ปิดตัวอย่าง', changesResizePreview: 'ปรับขนาดตัวอย่าง', - changesDiffOpenTitle: 'diff เปิดเป็น', - changesDiffOpenDesc: 'การกระทำ «เปิดในแท็บ diff แยก» วาง diff ไว้ที่ใด', - changesDiffOpenFloat: 'หน้าต่างลอย', - changesDiffOpenFloatDesc: 'หน้าต่างลอยกลางจอ — ลาก ย่อขยาย ปักหมุดไว้บนสุด', - changesDiffOpenPane: 'แพเนล', - changesDiffOpenPaneDesc: 'จอดใต้แพเนลต้นทาง (แยก diff สไตล์ VSCode)', changesLoadError: 'บันทึกไฟล์ของเซสชันยังใช้ไม่ได้ตอนนี้', staged: 'Staged', unstaged: 'Unstaged', @@ -234,13 +231,6 @@ export const th: Record = { settingsIntro: 'จัดการสิ่งที่การ์ดด้านข้างแสดงและพฤติกรรมของมัน', settingsPopupDesc: 'กำหนดค่าตัวเลือกที่เกี่ยวข้องสำหรับ {feature}', settingsDone: 'เสร็จสิ้น', - settingsOpenTitle: 'เปิดโดยค่าเริ่มต้นสำหรับแชทใหม่', - settingsOpenDesc: 'ขยายการ์ดด้านข้างโดยอัตโนมัติสำหรับแชทใหม่; แชทที่มีอยู่แล้วยังคงเค้าโครงของตัวเอง', - settingsWidthTitle: 'สัดส่วนความกว้างเริ่มต้น', - settingsWidthDesc: 'สัดส่วนความกว้างเริ่มต้นของการ์ดด้านข้างจากความกว้างหน้าต่างสำหรับแชทใหม่ (20–60)', - settingsWidthSuffix: '%', - settingsOpenPathTitle: 'เปิดไฟล์แชทในแถบด้านข้าง', - settingsOpenPathDesc: 'เปิดลิงก์ไฟล์ในแชท (แถวเครื่องมือ, ไฟล์ที่สร้าง, การกล่าวถึง) ในตัวแก้ไขแถบด้านข้างแทนแอปเริ่มต้นของระบบ', settingsOpenToolsTitle: 'ฉีดเครื่องมือเปิดแถบด้านข้างสำหรับโมเดล', settingsOpenToolsDesc: 'เมื่อเปิดใช้ โมเดลสามารถเปิดไฟล์ โฟลเดอร์ และหน้า HTTP(S) ในแถบด้านข้างผ่านเครื่องมือ sidebar_open (ปิดเป็นค่าเริ่มต้น)', settingsTitleBarTitle: 'โหมดความเข้ากันได้ของตำแหน่ง', diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts index 843dd67..e12ca75 100644 --- a/src/client/locales-tr.ts +++ b/src/client/locales-tr.ts @@ -17,6 +17,12 @@ /** The tr dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const tr: Record = { files: 'Dosyalar', + guideDescFiles: 'Çalışma alanı dosya ağacına göz atın; tıklayınca önizleme veya düzenleme açılır', + guideDescGit: 'Git değişiklikleri ve bu oturumun dosya değişiklikleri tek sekmede', + guideDescSubagent: 'Alt ajan topolojisi ve arka plan görevleri, canlı', + guideDescSidechat: 'Bu oturumun bağlamını devralan bağımsız bir yan konu', + guideDescTerminal: 'Gerçek bir shell (xterm + pty), yeniden bağlanma destekli', + guideDescBrowser: 'Sohbet bağlantılarını devralabilen gömülü tarayıcı', changesSessionEmpty: 'Bu oturumda henüz dosya işlemi yok', changesRead: 'Okuma', changesMdReading: 'Okuma', @@ -81,7 +87,6 @@ export const tr: Record = { closeOtherTabs: 'Diğer sekmeleri kapat', closeLeftTabs: 'Soldaki sekmeleri kapat', closeRightTabs: 'Sağdaki sekmeleri kapat', - moveToFreeWindow: 'Serbest pencereye taşı', floatDropHint: 'Serbest pencerede açmak için bırakın', dockToSidebar: 'Kenar çubuğuna geri dön', pinTerminal: 'Terminali Sabitle', @@ -96,8 +101,6 @@ export const tr: Record = { pinnedTerminalScopeGlobal: 'Genel olarak sabitlendi', pinnedRailLabel: 'Sabitlenmiş Terminaller', closePinnedTerminal: 'Terminali Kapat', - collapse: 'Kenar çubuğunu daralt', - expand: 'Kenar çubuğunu genişlet', collapseBottomPanel: 'Alt paneli daralt', expandBottomPanel: 'Alt paneli genişlet', terminalError: 'Terminal bağlantısı başarısız', @@ -155,12 +158,6 @@ export const tr: Record = { changesOpenDiffTab: 'Ayrı bir diff sekmesinde aç', changesClosePreview: 'Önizlemeyi kapat', changesResizePreview: 'Önizlemeyi boyutlandır', - changesDiffOpenTitle: 'diff açılır', - changesDiffOpenDesc: '"Ayrı bir diff sekmesinde aç" eylemi diff\'i nereye koyar', - changesDiffOpenFloat: 'Serbest pencere', - changesDiffOpenFloatDesc: 'Görünümün ortasında yüzen pencere — sürükle, boyutlandır, üstte tut', - changesDiffOpenPane: 'Panel', - changesDiffOpenPaneDesc: 'Kaynak panelin altına sabitlenmiş (VSCode tarzı diff bölmesi)', changesLoadError: 'Oturum dosya kayıtları şu anda kullanılamıyor', staged: 'Sahnelendi', unstaged: 'Sahnelenmedi', @@ -234,13 +231,6 @@ export const tr: Record = { settingsIntro: 'Yan kartın ne gösterdiğini ve nasıl davrandığını yönetin', settingsPopupDesc: '«{feature}» için ilgili seçenekleri yapılandırın', settingsDone: 'Bitti', - settingsOpenTitle: 'Yeni sohbetlerde varsayılan olarak aç', - settingsOpenDesc: 'Yepyeni sohbetler için yan kartı otomatik genişlet; var olan sohbetler kendi düzenlerini korur', - settingsWidthTitle: 'Varsayılan genişlik payı', - settingsWidthDesc: 'Yeni sohbetler için yan kartın pencere genişliğindeki varsayılan payı (20–60)', - settingsWidthSuffix: '%', - settingsOpenPathTitle: 'Sohbet dosyalarını kenar çubuğunda aç', - settingsOpenPathDesc: 'Sohbetteki dosya bağlantılarını (araç satırları, üretilen dosyalar, anılmalar) sistem varsayılan uygulaması yerine kenar çubuğu düzenleyicisinde aç', settingsOpenToolsTitle: 'Model için kenar çubuğu açma aracı enjekte et', settingsOpenToolsDesc: 'Etkinleştirildiğinde model, sidebar_open aracıyla kenar çubuğunda dosyaları, klasörleri ve HTTP(S) sayfalarını açabilir (varsayılan kapalı)', settingsTitleBarTitle: 'Konum uyumluluk modu', diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts index 34e9570..78e72ca 100644 --- a/src/client/locales-vi.ts +++ b/src/client/locales-vi.ts @@ -17,6 +17,12 @@ /** The vi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const vi: Record = { files: 'Tệp', + guideDescFiles: 'Duyệt cây tệp của workspace; bấm để xem trước hoặc chỉnh sửa', + guideDescGit: 'Thay đổi Git và thay đổi tệp của phiên này trong một tab', + guideDescSubagent: 'Sơ đồ subagent và tác vụ nền, theo thời gian thực', + guideDescSidechat: 'Luồng hỏi tiếp độc lập kế thừa ngữ cảnh của phiên này', + guideDescTerminal: 'Shell thật (xterm + pty), tự kết nối lại', + guideDescBrowser: 'Trình duyệt nhúng, có thể mở link trong chat', changesSessionEmpty: 'Chưa có thao tác tệp nào trong phiên này', changesRead: 'Đọc', changesMdReading: 'Đọc', @@ -81,7 +87,6 @@ export const vi: Record = { closeOtherTabs: 'Đóng các tab khác', closeLeftTabs: 'Đóng tab bên trái', closeRightTabs: 'Đóng tab bên phải', - moveToFreeWindow: 'Chuyển sang cửa sổ tự do', floatDropHint: 'Thả để mở trong cửa sổ tự do', dockToSidebar: 'Quay lại thanh bên', pinTerminal: 'Ghim Terminal', @@ -96,8 +101,6 @@ export const vi: Record = { pinnedTerminalScopeGlobal: 'Đã ghim toàn cục', pinnedRailLabel: 'Terminal đã ghim', closePinnedTerminal: 'Đóng Terminal', - collapse: 'Thu gọn thanh bên', - expand: 'Mở rộng thanh bên', collapseBottomPanel: 'Thu gọn panel dưới', expandBottomPanel: 'Mở rộng panel dưới', terminalError: 'Kết nối terminal thất bại', @@ -155,12 +158,6 @@ export const vi: Record = { changesOpenDiffTab: 'Mở trong tab diff riêng', changesClosePreview: 'Đóng xem trước', changesResizePreview: 'Điều chỉnh kích thước xem trước', - changesDiffOpenTitle: 'diff mở thành', - changesDiffOpenDesc: 'Hành động «mở trong tab diff riêng» đặt diff ở đâu', - changesDiffOpenFloat: 'Cửa sổ tự do', - changesDiffOpenFloatDesc: 'Cửa sổ nổi giữa khung nhìn — kéo, đổi kích thước, ghim trên cùng', - changesDiffOpenPane: 'Bảng', - changesDiffOpenPaneDesc: 'Gắn dưới bảng nguồn (chia diff kiểu VSCode)', changesLoadError: 'Bản ghi tệp phiên hiện không khả dụng', staged: 'Đã stage', unstaged: 'Chưa stage', @@ -234,13 +231,6 @@ export const vi: Record = { settingsIntro: 'Quản lý nội dung hiển thị và hành vi mặc định của thẻ bên', settingsPopupDesc: 'Cấu hình tùy chọn cho «{feature}»', settingsDone: 'Xong', - settingsOpenTitle: 'Mở mặc định cho phiên mới', - settingsOpenDesc: 'Tự động mở rộng thẻ bên cho phiên mới; phiên đã có giữ nguyên bố cục', - settingsWidthTitle: 'Tỷ lệ chiều rộng mặc định', - settingsWidthDesc: 'Tỷ lệ phần trăm chiều rộng cửa sổ mà thẻ bên chiếm cho phiên mới (20–60)', - settingsWidthSuffix: '%', - settingsOpenPathTitle: 'Mở tệp chat trong thanh bên', - settingsOpenPathDesc: 'Khi nhấp link tệp trong chat (dòng công cụ, danh sách sản phẩm, nhắc tệp), mở trong trình soạn thảo thanh bên thay vì ứng dụng mặc định hệ thống', settingsOpenToolsTitle: 'Tiêm công cụ mở thanh bên cho mô hình', settingsOpenToolsDesc: 'Khi bật, mô hình có thể mở tệp, thư mục và trang HTTP(S) trong thanh bên qua công cụ sidebar_open (mặc định tắt)', settingsTitleBarTitle: 'Chế độ tương thích thanh tiêu đề', diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts index f21ac38..8c03489 100644 --- a/src/client/locales-zh-HK.ts +++ b/src/client/locales-zh-HK.ts @@ -32,6 +32,12 @@ /** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const zhHK: Record = { files: '檔案', + guideDescFiles: '瀏覽工作區檔案樹,點開即可預覽或編輯', + guideDescGit: 'Git 變更與本輪檔案改動,雙視角合一', + guideDescSubagent: '子代理拓撲與背景任務,即時狀態', + guideDescSidechat: '繼承目前工作階段上下文的獨立追問執行緒', + guideDescTerminal: '真實 shell 終端(xterm + pty),斷線重連', + guideDescBrowser: '內嵌網頁瀏覽器,可接管聊天中的外鏈', changesSessionEmpty: '本工作階段還沒有檔案操作', changesRead: '讀取', changesMdReading: '閱讀', @@ -96,7 +102,6 @@ export const zhHK: Record = { closeOtherTabs: '關閉其他標籤', closeLeftTabs: '關閉左側標籤', closeRightTabs: '關閉右側標籤', - moveToFreeWindow: '移動到自由視窗', floatDropHint: '放開以在自由視窗中開啟', dockToSidebar: '返回側邊欄', pinTerminal: '固定終端', @@ -111,8 +116,6 @@ export const zhHK: Record = { pinnedTerminalScopeGlobal: '固定到全域', pinnedRailLabel: '固定終端', closePinnedTerminal: '關閉終端', - collapse: '收起側邊欄', - expand: '展開側邊欄', collapseBottomPanel: '收起底部面板', expandBottomPanel: '展開底部面板', terminalError: '終端連線失敗', @@ -170,12 +173,6 @@ export const zhHK: Record = { changesOpenDiffTab: '在獨立分頁中開啟', changesClosePreview: '關閉預覽', changesResizePreview: '調整預覽高度', - changesDiffOpenTitle: '差異展開方式', - changesDiffOpenDesc: '「展開為獨立分頁」把 diff 放到哪裡', - changesDiffOpenFloat: '浮動視窗', - changesDiffOpenFloatDesc: '作為自由視窗置中開啟,可拖拽、縮放、置頂', - changesDiffOpenPane: '面板', - changesDiffOpenPaneDesc: '停駐在來源面板下方(VSCode 式 diff 分欄)', changesLoadError: '工作階段檔案記錄暫不可用', staged: '已暫存', unstaged: '未暫存', @@ -249,13 +246,6 @@ export const zhHK: Record = { settingsIntro: '管理側邊卡片的顯示內容與預設行為', settingsPopupDesc: '為「{feature}」設定相關選項', settingsDone: '完成', - settingsOpenTitle: '新工作階段預設開啟', - settingsOpenDesc: '新增工作階段時自動展開側邊卡片;已存在的工作階段保持各自佈局', - settingsWidthTitle: '預設寬度佔比', - settingsWidthDesc: '新增工作階段時側邊卡片佔視窗寬度的百分比 (20–60)', - settingsWidthSuffix: '%', - settingsOpenPathTitle: '聊天區檔案在側邊欄開啟', - settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式', settingsOpenToolsTitle: '為模型注入側邊欄開啟工具', settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)', settingsTitleBarTitle: '位置相容模式', diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts index 8270392..4615010 100644 --- a/src/client/locales-zh-MO.ts +++ b/src/client/locales-zh-MO.ts @@ -32,6 +32,12 @@ /** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const zhMO: Record = { files: '檔案', + guideDescFiles: '瀏覽工作區檔案樹,點開即可預覽或編輯', + guideDescGit: 'Git 變更與本輪檔案改動,雙視角合一', + guideDescSubagent: '子代理拓撲與背景任務,即時狀態', + guideDescSidechat: '繼承目前工作階段上下文的獨立追問執行緒', + guideDescTerminal: '真實 shell 終端(xterm + pty),斷線重連', + guideDescBrowser: '內嵌網頁瀏覽器,可接管聊天中的外鏈', changesSessionEmpty: '本工作階段還沒有檔案操作', changesRead: '讀取', changesMdReading: '閱讀', @@ -96,7 +102,6 @@ export const zhMO: Record = { closeOtherTabs: '關閉其他標籤', closeLeftTabs: '關閉左側標籤', closeRightTabs: '關閉右側標籤', - moveToFreeWindow: '移動到自由視窗', floatDropHint: '放開以在自由視窗中開啟', dockToSidebar: '返回側邊欄', pinTerminal: '固定終端', @@ -111,8 +116,6 @@ export const zhMO: Record = { pinnedTerminalScopeGlobal: '固定到全域', pinnedRailLabel: '固定終端', closePinnedTerminal: '關閉終端', - collapse: '收起側邊欄', - expand: '展開側邊欄', collapseBottomPanel: '收起底部面板', expandBottomPanel: '展開底部面板', terminalError: '終端連線失敗', @@ -170,12 +173,6 @@ export const zhMO: Record = { changesOpenDiffTab: '在獨立分頁中開啟', changesClosePreview: '關閉預覽', changesResizePreview: '調整預覽高度', - changesDiffOpenTitle: '差異展開方式', - changesDiffOpenDesc: '「展開為獨立分頁」把 diff 放到哪裡', - changesDiffOpenFloat: '浮動視窗', - changesDiffOpenFloatDesc: '作為自由視窗置中開啟,可拖拽、縮放、置頂', - changesDiffOpenPane: '面板', - changesDiffOpenPaneDesc: '停駐在來源面板下方(VSCode 式 diff 分欄)', changesLoadError: '工作階段檔案記錄暫不可用', staged: '已暫存', unstaged: '未暫存', @@ -249,13 +246,6 @@ export const zhMO: Record = { settingsIntro: '管理側邊卡片的顯示內容與預設行為', settingsPopupDesc: '為「{feature}」設定相關選項', settingsDone: '完成', - settingsOpenTitle: '新工作階段預設開啟', - settingsOpenDesc: '新增工作階段時自動展開側邊卡片;已存在的工作階段保持各自佈局', - settingsWidthTitle: '預設寬度佔比', - settingsWidthDesc: '新增工作階段時側邊卡片佔視窗寬度的百分比 (20–60)', - settingsWidthSuffix: '%', - settingsOpenPathTitle: '聊天區檔案在側邊欄開啟', - settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式', settingsOpenToolsTitle: '為模型注入側邊欄開啟工具', settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)', settingsTitleBarTitle: '位置相容模式', diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts index 1acab65..23e7f6d 100644 --- a/src/client/locales-zh-TW.ts +++ b/src/client/locales-zh-TW.ts @@ -32,6 +32,12 @@ /** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const zhTW: Record = { files: '檔案', + guideDescFiles: '瀏覽工作區檔案樹,點開即可預覽或編輯', + guideDescGit: 'Git 變更與本輪檔案改動,雙視角合一', + guideDescSubagent: '子代理拓撲與背景任務,即時狀態', + guideDescSidechat: '繼承目前工作階段上下文的獨立追問執行緒', + guideDescTerminal: '真實 shell 終端(xterm + pty),斷線重連', + guideDescBrowser: '內嵌網頁瀏覽器,可接管聊天中的外鏈', changesSessionEmpty: '本作業階段還沒有檔案操作', changesRead: '讀取', changesMdReading: '閱讀', @@ -96,7 +102,6 @@ export const zhTW: Record = { closeOtherTabs: '關閉其他標籤', closeLeftTabs: '關閉左側標籤', closeRightTabs: '關閉右側標籤', - moveToFreeWindow: '移動到自由視窗', floatDropHint: '放開以在自由視窗中開啟', dockToSidebar: '返回側邊欄', pinTerminal: '固定終端', @@ -111,8 +116,6 @@ export const zhTW: Record = { pinnedTerminalScopeGlobal: '固定到全域', pinnedRailLabel: '固定終端', closePinnedTerminal: '關閉終端', - collapse: '收起側邊欄', - expand: '展開側邊欄', collapseBottomPanel: '收起底部面板', expandBottomPanel: '展開底部面板', terminalError: '終端連線失敗', @@ -170,12 +173,6 @@ export const zhTW: Record = { changesOpenDiffTab: '在獨立頁籤中開啟', changesClosePreview: '關閉預覽', changesResizePreview: '調整預覽高度', - changesDiffOpenTitle: '差異展開方式', - changesDiffOpenDesc: '「展開為獨立頁籤」把 diff 放到哪裡', - changesDiffOpenFloat: '浮動視窗', - changesDiffOpenFloatDesc: '作為自由視窗置中開啟,可拖拽、縮放、置頂', - changesDiffOpenPane: '面板', - changesDiffOpenPaneDesc: '停駐在來源面板下方(VSCode 式 diff 分欄)', changesLoadError: '工作階段檔案記錄暫不可用', staged: '已暫存', unstaged: '未暫存', @@ -249,13 +246,6 @@ export const zhTW: Record = { settingsIntro: '管理側邊卡片的顯示內容與預設行為', settingsPopupDesc: '為「{feature}」設定相關選項', settingsDone: '完成', - settingsOpenTitle: '新工作階段預設開啟', - settingsOpenDesc: '新增工作階段時自動展開側邊卡片;已存在的工作階段保持各自佈局', - settingsWidthTitle: '預設寬度佔比', - settingsWidthDesc: '新增工作階段時側邊卡片佔視窗寬度的百分比 (20–60)', - settingsWidthSuffix: '%', - settingsOpenPathTitle: '聊天區檔案在側邊欄開啟', - settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式', settingsOpenToolsTitle: '為模型注入側邊欄開啟工具', settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)', settingsTitleBarTitle: '位置相容模式', diff --git a/src/client/locales.ts b/src/client/locales.ts index 94f223c..f418f86 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -24,6 +24,12 @@ /** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */ export const zh = { files: '文件', + guideDescFiles: '浏览工作区文件树,点开即预览或编辑', + guideDescGit: 'Git 变更与本轮文件改动,双视角合一', + guideDescSubagent: '子代理拓扑与后台任务,实时状态', + guideDescSidechat: '继承当前会话上下文的独立追问线程', + guideDescTerminal: '真实 shell 终端(xterm + pty),断线重连', + guideDescBrowser: '内嵌网页浏览器,可接管聊天里的外链', changesSessionEmpty: '本会话还没有文件操作', changesRead: '读取', changesMdReading: '阅读', @@ -93,7 +99,6 @@ export const zh = { closeOtherTabs: '关闭其他页签', closeLeftTabs: '关闭左侧页签', closeRightTabs: '关闭右侧页签', - moveToFreeWindow: '移动到自由窗口', floatDropHint: '松开以在自由窗口中打开', dockToSidebar: '回到侧边栏', pinTerminal: '固定终端', @@ -108,8 +113,6 @@ export const zh = { pinnedTerminalScopeGlobal: '固定到全局', pinnedRailLabel: '固定终端', closePinnedTerminal: '关闭终端', - collapse: '折叠侧边栏', - expand: '展开侧边栏', collapseBottomPanel: '折叠底部面板', expandBottomPanel: '展开底部面板', terminalError: '终端连接失败', @@ -166,12 +169,6 @@ export const zh = { changesOpenDiffTab: '在独立页签中打开', changesClosePreview: '关闭预览', changesResizePreview: '调整预览高度', - changesDiffOpenTitle: '差异展开方式', - changesDiffOpenDesc: '「展开为独立页签」把 diff 放到哪里', - changesDiffOpenFloat: '浮窗', - changesDiffOpenFloatDesc: '作为自由窗口居中打开,可拖拽、缩放、置顶', - changesDiffOpenPane: '面板', - changesDiffOpenPaneDesc: '停靠在源面板下方(VSCode 式 diff 分栏)', changesLoadError: '会话文件记录暂不可用', staged: '已暂存', unstaged: '未暂存', @@ -246,13 +243,6 @@ export const zh = { settingsIntro: '管理侧边卡片的显示内容与默认行为', settingsPopupDesc: '为「{feature}」配置相关选项', settingsDone: '完成', - settingsOpenTitle: '新会话默认打开', - settingsOpenDesc: '新建会话时自动展开侧边卡片;已存在的会话保持各自布局', - settingsWidthTitle: '默认宽度占比', - settingsWidthDesc: '新建会话时侧边卡片占窗口宽度的百分比 (20–60)', - settingsWidthSuffix: '%', - settingsOpenPathTitle: '聊天区文件在侧边栏打开', - settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用', settingsOpenToolsTitle: '为模型注入侧边栏打开工具', settingsOpenToolsDesc: '开启后,模型可通过 sidebar_open 工具在侧边栏主动打开文件、文件夹和 HTTP(S) 网页(默认关闭)', settingsTitleBarTitle: '位置兼容模式', @@ -476,6 +466,12 @@ export const zh = { /** The en dictionary (key-set-equal to zh, enforced by the type annotation). */ export const en: Record = { files: 'Files', + guideDescFiles: 'Browse the workspace tree; click a file to preview or edit', + guideDescGit: 'Git changes and this session\'s file edits, in one tab', + guideDescSubagent: 'Subagent topology and background jobs, live', + guideDescSidechat: 'An independent follow-up thread inheriting this session\'s context', + guideDescTerminal: 'A real shell (xterm + pty) with reconnect', + guideDescBrowser: 'An embedded browser that can take over chat links', changesSessionEmpty: 'No file operations in this session yet', changesRead: 'Read', changesMdReading: 'Reading', @@ -545,7 +541,6 @@ export const en: Record = { closeOtherTabs: 'Close Other Tabs', closeLeftTabs: 'Close Tabs to the Left', closeRightTabs: 'Close Tabs to the Right', - moveToFreeWindow: 'Move to Free Window', floatDropHint: 'Release to open in a free window', dockToSidebar: 'Dock Back to Sidebar', pinTerminal: 'Pin Terminal', @@ -560,8 +555,6 @@ export const en: Record = { pinnedTerminalScopeGlobal: 'Pinned globally', pinnedRailLabel: 'Pinned Terminals', closePinnedTerminal: 'Close Terminal', - collapse: 'Collapse sidebar', - expand: 'Expand sidebar', collapseBottomPanel: 'Collapse bottom panel', expandBottomPanel: 'Expand bottom panel', terminalError: 'Terminal connection failed', @@ -618,12 +611,6 @@ export const en: Record = { changesOpenDiffTab: 'Open in a diff tab', changesClosePreview: 'Close preview', changesResizePreview: 'Resize preview', - changesDiffOpenTitle: 'Diff opens as', - changesDiffOpenDesc: 'Where the "expand to a diff tab" action lands the diff', - changesDiffOpenFloat: 'Free window', - changesDiffOpenFloatDesc: 'A floating window centered on the viewport — drag, resize, keep on top', - changesDiffOpenPane: 'Pane', - changesDiffOpenPaneDesc: 'Docked below the source panel (VSCode-style diff split)', changesLoadError: 'Session file records are unavailable right now', staged: 'Staged', unstaged: 'Unstaged', @@ -698,13 +685,6 @@ export const en: Record = { settingsIntro: 'Manage what the side card shows and how it behaves', settingsPopupDesc: 'Configure related options for {feature}', settingsDone: 'Done', - settingsOpenTitle: 'Open by default for new conversations', - settingsOpenDesc: 'Expand the side card automatically for brand-new conversations; existing conversations keep their own layouts', - settingsWidthTitle: 'Default width share', - settingsWidthDesc: 'The side card\'s default share of the window width for new conversations (20–60)', - settingsWidthSuffix: '%', - settingsOpenPathTitle: 'Open chat files in the sidebar', - settingsOpenPathDesc: 'Open file links in the chat (tool rows, produced files, mentions) in the sidebar editor instead of the system default app', settingsOpenToolsTitle: 'Inject the sidebar-open tool for the model', settingsOpenToolsDesc: 'When enabled, the model can actively open files, folders, and HTTP(S) pages in the sidebar through the sidebar_open tool (off by default)', settingsTitleBarTitle: 'Position compatibility mode', diff --git a/src/client/native/index.ts b/src/client/native/index.ts new file mode 100644 index 0000000..e082548 --- /dev/null +++ b/src/client/native/index.ts @@ -0,0 +1,282 @@ +/** + * The plugin's content, registered into DSH's native right Sidebar. + * + * Every tab the plugin can draw becomes a native tab TYPE plus a BODY: + * + * - the `editor` type is both a page (the files window) and a resource + * viewer — it claims `dsh-resource://file/**` at the default `extension` + * band, which outranks the built-in `text` preview (`fallback`) and the + * built-in `files` tree (`builtin`), so every file the product opens lands + * in the plugin's editor; + * - the built-in `files` page kind is TAKEN OVER by an `extension` + * registration of the same kind, so `openTab('files')` draws the plugin's + * explorer instead of the built-in tree; the built-in resumes when this + * plugin unregisters; + * - every other descriptor (changes / subagent / side chat / terminal / + * browser / diff) becomes a page type of its own. + * + * The registrations follow the plugin's registry lifecycle: a descriptor + * registered later (an external plugin) gets its native type too, and a type + * the user disabled in the side-card settings is unregistered — so it is + * absent from the native guide and `openTab` refuses it. + */ +import type { Context } from '../../context-types.ts' +import { t } from '../locales.ts' +import { parseFileAddress } from '../resource-address.ts' +import type { BetterSidebarService, TabDescriptor } from '../service.ts' +import type { SidebarStore } from '../state.ts' +import { + NativeTabBody, + NativeTabTitle, + type NativeBodyInjected, + type NativeTabInfo, + type NativeTabParams, + type NativeTabRecords, +} from './tab-adapter.tsx' + +/** The native tab-type registry face (`ctx.sidebarRightTabs`). */ +interface NativeTabRegistry { + register(definition: { + id: string + kind: string + patterns?: readonly string[] + priority?: 'extension' | 'builtin' | 'fallback' + canOpen?: (address: string) => boolean + title: (address: string) => string + guide?: readonly { order: number; title: () => string; description?: () => string; icon?: unknown }[] + }): () => void +} + +/** The kind the plugin's files window owns (page + file viewer). */ +const EDITOR_KIND = 'editor' + +/** The built-in page kind this plugin takes over. */ +const FILES_KIND = 'files' + +/** The plugin's implementation id for a descriptor (unique across kinds). */ +function nativeId(descriptorId: string): string { + return `dsh-better-sidebar:${descriptorId}` +} + +/** The descriptor's title text, evaluated fresh for the current locale. */ +function titleOf(descriptor: TabDescriptor): string { + return typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title +} + +/** + * The guide fields carrying the descriptor's own description line, evaluated + * fresh for the current locale. DSH 0.1.5-rc.1 renders `description` only + * while the guide lists at most 4 entries, and a descriptor that declares + * none must reach the host with NO `description` field at all — the host has + * no fallback of its own, so an empty string would render as a blank second + * line rather than a clean title-only capsule. + * @param descriptor - the tab descriptor owning the guide entry. + * @returns the `description` field, or an empty object. + */ +function guideDescriptionOf(descriptor: TabDescriptor | undefined): { description?: () => string } { + const description = descriptor?.description + return description === undefined ? {} : { description: () => (typeof description === 'function' ? description() : description) } +} + +/** + * The guide row's glyph for a descriptor icon (nothing when it has none). + * The native guide renders `entry.icon`, so a takeover registered without one + * is the only row in the list with a blank leading slot. + * @param icon - the descriptor's icon value. + * @returns the guide-entry icon fields, or an empty object. + */ +function guideIconOf(icon: TabDescriptor['icon']): { icon?: (props: { size?: number }) => unknown } { + return typeof icon === 'function' ? { icon: (props: { size?: number }) => icon(props.size ?? 16) } : {} +} + +/** + * The chip title of a file address: its own file name. A resource tab is + * identified by its address, so the strip must name the FILE — the + * descriptor's title ("Files") would make every open file look identical. + * @param address - the native tab's content address. + * @returns the file name, or undefined when the address is not a file. + */ +function fileTitleOf(address: string): string | undefined { + const parsed = parseFileAddress(address) + if (parsed === undefined) return undefined + const segments = parsed.path.split('/').filter(segment => segment !== '') + return segments.length === 0 ? undefined : segments[segments.length - 1] +} + +/** One descriptor's live native registrations. */ +interface Registration { + readonly dispose: () => void +} + +/** Everything the registrations need. */ +export interface NativeSurfaceDeps { + readonly ctx: Context + readonly store: SidebarStore + readonly service: BetterSidebarService + /** The shared native tab record registry (the surface writes it too). */ + readonly records: NativeTabRecords +} + +/** + * Register the plugin's tabs into the native right Sidebar and keep them in + * step with the plugin's own registry and settings. + * @param deps - client context, the plugin store/service, and the records. + * @returns a disposer unregistering everything. + */ +export function registerNativeSurface(deps: NativeSurfaceDeps): () => void { + const { ctx, store, service, records } = deps + // Wait for the tab-type REGISTRY (a service), not for the slot declaration: + // the native seat declares `sidebar.right.pane.tab` BEFORE it provides + // `sidebarRightTabs`, so a declaration-triggered registration reads the + // service as missing and — because the declaration never collapses — + // registers nothing, permanently. Observed on a real profile: the + // declaration callback fired with both `sidebarRight` and + // `sidebarRightTabs` undefined, and the registry appeared a moment later. + // `ctx.inject` re-runs this body whenever the service appears/reappears, + // which is the lifecycle the registrations need. + const seat = ctx.inject(['sidebarRightTabs'], (injected) => { + const tabs = injected.get('sidebarRightTabs') as unknown as NativeTabRegistry | undefined + if (tabs === undefined) return + const live = new Map() + + const fileParamsOf = (info: NativeTabInfo): NativeTabParams | undefined => { + const address = parseFileAddress(info.tab.contentId) + return address === undefined ? undefined : { path: address.path } + } + const fileSessionIdOf = (info: NativeTabInfo): string | undefined => { + const address = parseFileAddress(info.tab.contentId) + return address !== undefined && address.scope === 'session' ? address.sessionId : undefined + } + + /** Register the body + chip-title slots for one native implementation id. */ + const registerSlots = ( + id: string, + injected: Omit, + params: Pick, + ): Array<() => void> => [ + ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ + name: 'sidebar.right.pane.tab', + key: id, + inject: (sessionId: string) => ({ ...injected, ...params, sessionId }), + }, NativeTabBody)), + ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({ + name: 'sidebar.right.pane.tab.title', + key: id, + inject: () => ({ records }), + }, NativeTabTitle)), + ] + + /** One descriptor's native type + body + title, as one disposable. */ + const registerDescriptor = (descriptor: TabDescriptor): (() => void) => { + const id = nativeId(descriptor.id) + const isEditor = descriptor.id === EDITOR_KIND + const icon = descriptor.icon + const disposeType = tabs.register({ + id, + kind: descriptor.id, + ...(isEditor + ? { + patterns: ['dsh-resource://file/**'], + canOpen: (address: string) => parseFileAddress(address) !== undefined, + } + : {}), + // An external implementation outranks the product's own viewers, which + // is what lets the plugin's editor take over file addresses. + priority: 'extension', + // A resource tab is titled by the file it shows; a page tab keeps the + // descriptor's own title. + title: (address: string) => (isEditor ? fileTitleOf(address) ?? titleOf(descriptor) : titleOf(descriptor)), + // No guide entry for the editor: its page identity is the `files` + // kind takeover below (same view, same title), so listing both would + // offer the reader two identical "Files" rows. The type itself stays + // registered as the file RESOURCE viewer. + ...(descriptor.hidden === true || isEditor + ? {} + : { + guide: [{ + order: descriptor.order ?? 100, + title: () => titleOf(descriptor), + ...guideDescriptionOf(descriptor), + ...guideIconOf(icon), + }], + }), + }) + const slots = registerSlots( + id, + { ctx, store, service, records, descriptorId: descriptor.id }, + isEditor ? { paramsOf: fileParamsOf, sessionIdOf: fileSessionIdOf } : {}, + ) + return () => { + for (const dispose of slots.reverse()) dispose() + disposeType() + } + } + + /** One `files`-kind takeover: the plugin's explorer under the built-in kind. */ + const registerFilesKind = (editor: TabDescriptor | undefined): (() => void) => { + const id = 'dsh-better-sidebar:files' + const disposeType = tabs.register({ + id, + kind: FILES_KIND, + priority: 'extension', + title: () => t('files'), + guide: [{ + order: 10, + title: () => t('files'), + // The takeover IS the editor descriptor's page, so it carries the + // editor's glyph AND guide line: without them the "Files" row is + // the only guide entry with a blank icon slot and no description. + ...guideDescriptionOf(editor), + ...guideIconOf(editor?.icon), + }], + }) + const slots = registerSlots(id, { ctx, store, service, records, descriptorId: EDITOR_KIND }, {}) + return () => { + for (const dispose of slots.reverse()) dispose() + disposeType() + } + } + + /** Bring the live registrations in line with the registry + the settings. */ + const sync = (): void => { + const wanted = new Map () => void>() + for (const descriptor of service.getTabs()) { + if (!service.isTabEnabled(descriptor.id)) continue + wanted.set(descriptor.id, () => registerDescriptor(descriptor)) + } + for (const [descriptorId, registration] of live) { + if (wanted.has(descriptorId)) continue + registration.dispose() + live.delete(descriptorId) + } + for (const [descriptorId, create] of wanted) { + if (live.has(descriptorId)) continue + live.set(descriptorId, { dispose: create() }) + } + // The built-in files kind follows the editor type's switch: with the + // editor disabled the plugin has no explorer to put there. + const wantsFiles = service.isTabEnabled(EDITOR_KIND) + const hasFiles = live.has(FILES_KIND) + if (wantsFiles && !hasFiles) { + live.set(FILES_KIND, { dispose: registerFilesKind(service.getTab(EDITOR_KIND)) }) + } + if (!wantsFiles && hasFiles) { + live.get(FILES_KIND)?.dispose() + live.delete(FILES_KIND) + } + } + + const disposeSubscriptions = [service.subscribe(sync), store.subscribe(sync)] + sync() + return () => { + for (const registration of live.values()) registration.dispose() + live.clear() + for (const dispose of disposeSubscriptions.reverse()) dispose() + } + }) + return () => { + void seat.dispose() + } +} + +export type { NativeTabRecords } diff --git a/src/client/native/surface.ts b/src/client/native/surface.ts new file mode 100644 index 0000000..6dc0861 --- /dev/null +++ b/src/client/native/surface.ts @@ -0,0 +1,149 @@ +/** + * The plugin's write face over DSH's native right Sidebar (`ctx.sidebarRight`). + * + * The service speaks in the plugin's own vocabulary (tab type, seed, session + * scope); this module turns those into the native surface's vocabulary + * (kind + navigation params, or a `dsh-resource://` address) and forwards + * tab-record operations to the plugin's native record registry. + * + * Two native limits shape the implementation: + * + * - the surface exists only while a session's panel is mounted, and the + * service's public face (`ISidebarRight`) writes only into THAT session. + * The controller also carries `openTabIn` / `openResourceIn` / + * `closeIn`, which act on any session whose store the runtime has minted; + * both are probed at call time, and an open for a session that has no + * store yet is QUEUED and replayed when that session comes on screen; + * - layout state is memory-only, so a queued open is not durable either. + */ +import type { Context } from '../../context-types.ts' +import { fileAddressFor } from '../resource-address.ts' +import type { NativeTabParams, SidebarSurface } from '../service.ts' +import type { NativeTabRecords } from './tab-adapter.tsx' + +/** One open the surface could not place yet. */ +type Pending = + | { kind: 'tab'; sessionId: string; tabKind: string; params: NativeTabParams; revealIfOpened: boolean } + | { kind: 'resource'; sessionId: string; address: string; line: number | undefined; revealIfOpened: 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 + 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 + closeIn?(sessionId: string, tabId: string): void +} + +/** The plugin's write face over the native surface. */ +export interface NativeSurface extends SidebarSurface { + /** Replay opens that were queued for a session that had no mounted surface. */ + flushPending(): void + /** Stop observing the session list. */ + dispose(): void +} + +/** The active session id, as the client list reports it. */ +function activeSessionId(ctx: Context): string | undefined { + try { + return ctx.sessions.list.getSnapshot().current + } catch { + return undefined + } +} + +/** + * Bind the plugin's write face to the native controller. + * @param ctx - the client context (session list + `ctx.sidebarRight`). + * @param records - the plugin's native tab record registry. + * @returns the surface, plus a disposer unbinding its session subscription. + */ +export function createNativeSurface(ctx: Context, records: NativeTabRecords): NativeSurface { + const pending: Pending[] = [] + const controller = (): NativeController | undefined => + ctx.get('sidebarRight') as unknown as NativeController | undefined + + const place = (entry: Pending): boolean => { + const api = controller() + if (api === undefined) return false + const active = activeSessionId(ctx) + const onScreen = active !== undefined && active === entry.sessionId + if (entry.kind === 'tab') { + const options = { params: entry.params, revealIfOpened: entry.revealIfOpened } + if (onScreen) { + api.openTab(entry.tabKind, options) + return true + } + if (api.openTabIn !== undefined) { + api.openTabIn(entry.sessionId, entry.tabKind, options) + return true + } + return false + } + const options = { + ...(entry.line === undefined ? {} : { params: { line: entry.line } }), + revealIfOpened: entry.revealIfOpened, + } + if (onScreen) { + api.openResource(entry.address, options) + return true + } + if (api.openResourceIn !== undefined) { + api.openResourceIn(entry.sessionId, entry.address, options) + return true + } + return false + } + + const flushPending = (): void => { + if (pending.length === 0) return + for (let index = pending.length - 1; index >= 0; index--) { + const entry = pending[index] + if (entry !== undefined && place(entry)) pending.splice(index, 1) + } + } + + const enqueue = (entry: Pending): void => { + if (!place(entry)) pending.push(entry) + } + + const unsubscribe = ctx.sessions.list.subscribe(flushPending) + return { + openTab({ sessionId, kind, params, revealIfOpened }) { + enqueue({ kind: 'tab', sessionId, tabKind: kind, params, revealIfOpened }) + }, + openResource({ sessionId, address, line, revealIfOpened }) { + enqueue({ kind: 'resource', sessionId, address, line, revealIfOpened }) + }, + fileAddress(sessionId, cwd, path) { + return fileAddressFor(sessionId, cwd, path) + }, + close(sessionId, tabId) { + const record = records.get(tabId) + if (record === undefined) return undefined + records.drop(tabId) + const api = controller() + if (api !== undefined) { + if (sessionId === activeSessionId(ctx)) api.close(tabId) + else if (api.closeIn !== undefined) api.closeIn(sessionId, tabId) + } + return { type: record.tab.type, title: record.tab.title } + }, + update(tabId, patch) { + if (!records.has(tabId)) return false + records.update(tabId, patch) + return true + }, + activate(tabId) { + // The native surface has no cross-pane activation face the plugin needs: + // a tab is focused by opening its (kind, address) again, which the + // native open already de-duplicates. + return records.has(tabId) + }, + has: tabId => records.has(tabId), + flushPending, + dispose: () => { unsubscribe() }, + } +} diff --git a/src/client/native/tab-adapter.tsx b/src/client/native/tab-adapter.tsx new file mode 100644 index 0000000..1000278 --- /dev/null +++ b/src/client/native/tab-adapter.tsx @@ -0,0 +1,354 @@ +/** + * The plugin's side of a native right-Sidebar tab. + * + * A plugin tab descriptor is one registration in the plugin's own registry + * (`ctx.betterSidebar`); this module adapts the native tab record to that + * descriptor's component contract. The native surface carries the tab's + * record (id, kind, title, content identity, navigation params, lifetime + * signal); the plugin's components expect a `SidebarTab` plus the explorer + * state the plugin's own layout used to hold — both live here. + * + * The adapter owns three things the native layout does not carry: + * + * - a synthetic plugin `SidebarTab` per native tab id, minted from the native + * record + `navigation.params` and kept live across navigations, so a + * component that retitles itself (`updateTab`) or rewrites its path (the + * editor's in-place file switch) keeps working; + * - the explorer's per-tab expansion/reveal sets; + * - the per-kind instance counter behind titles like "Terminal 2". + * + * Nothing here is a singleton: the registry is created once per client + * activation and handed to every registration. + */ +import { createElement, useEffect, useMemo, useSyncExternalStore } from 'react' +import type { ComponentType, ReactNode } from 'react' +import type { Context } from '../../context-types.ts' +import type { SessionScope } from '../api.ts' +import { RenderBoundary } from '../RenderBoundary.tsx' +import { OrphanedTab } from '../OrphanedTab.tsx' +import { referenceInChat } from '../reference-in-chat.ts' +import type { BetterSidebarService } from '../service.ts' +import type { SidebarStore, SidebarTab, TabType } from '../state.ts' +import css from '../sidebar.module.css' + +/** + * The plugin-side seed a native open carries in `navigation.params`. + * JSON-shaped by convention (the native surface does not validate it). + */ +export interface NativeTabParams { + /** Overrides the descriptor's title for this instance. */ + readonly title?: string + /** A file path (the editor window's content seed). */ + readonly path?: string + /** A URL the tab navigates to on mount (the browser tab's seed). */ + readonly url?: string + /** A diff reference (the diff tab's content seed). */ + readonly diff?: SidebarTab['diff'] + /** JSON-serializable custom state carried on the synthetic record. */ + readonly meta?: unknown + /** A line to land on (file addresses carry it as a navigation parameter). */ + readonly line?: number +} + +/** The native tab information this adapter reads (structural mirror of `useTabInfo`). */ +export interface NativeTabInfo { + readonly tab: { + readonly id: string + readonly kind: string + readonly title: string + readonly contentId: string + readonly visible: boolean + readonly navigation: { + readonly address: string + readonly params: NativeTabParams | undefined + readonly revision: number + } + readonly signal: AbortSignal + } +} + +/** One native tab's plugin-side view state. */ +interface View { + tab: SidebarTab + scope: SessionScope + expanded: string[] + revealed: string[] + /** Bumped on every mutation; the components subscribe to it. */ + version: number +} + +/** The plugin-side record registry for native tabs. */ +export interface NativeTabRecords { + /** + * The synthetic record for a native tab, minted on first sight and kept + * across navigations (a navigation refreshes the seed fields, never the + * identity or a plugin-side title/meta mutation). + * @param input - the native record and the session it lives in. + * @returns the current view state. + */ + ensure(input: { + id: string + kind: string + title: string + params: NativeTabParams | undefined + scope: SessionScope + /** + * The descriptor's own factory, called ONCE for a record that arrives + * without seed fields (a native guide open, which knows nothing about the + * plugin's per-instance minting): it supplies the title and the meta a + * view needs — the side chat's thread bootstrap, the terminal's name. + */ + mint?: () => { title?: string; meta?: unknown } | undefined + }): View + /** One record by native tab id. */ + get(id: string): View | undefined + /** Whether this id belongs to a native tab (vs the plugin's own layout). */ + has(id: string): boolean + /** Merge a patch into the synthetic record (the `updateTab` path). */ + update(id: string, patch: { title?: string; path?: string; meta?: unknown }): void + /** Forget a record (the native tab closed). */ + drop(id: string): void + /** Toggle one directory in a record's expansion set. */ + toggleExpanded(id: string, path: string): void + /** Mint the next instance number of a kind (titles like "Terminal 2"). */ + nextInstance(kind: string): number + /** A per-record version for `useSyncExternalStore`. */ + versionOf(id: string): number + /** Subscribe to record changes (title/path/meta/expanded). */ + subscribe(listener: () => void): () => void +} + +/** Create the record registry for one client activation. */ +export function createNativeTabRecords(): NativeTabRecords { + const views = new Map() + const instances = new Map() + const listeners = new Set<() => void>() + const notify = (): void => { for (const listener of listeners) listener() } + const put = (id: string, view: View): void => { + views.set(id, { ...view, version: view.version + 1 }) + notify() + } + return { + ensure({ id, kind, title, params, scope, mint }) { + const existing = views.get(id) + if (existing === undefined) { + const seeded = params?.title === undefined && params?.meta === undefined ? mint?.() : undefined + const meta = params?.meta ?? seeded?.meta + const minted: View = { + tab: { + id, + type: kind as TabType, + title: params?.title ?? seeded?.title ?? title, + ...(params?.path === undefined ? {} : { path: params.path }), + ...(params?.diff === undefined ? {} : { diff: params.diff }), + ...(meta === undefined ? {} : { meta }), + }, + scope, + expanded: [], + revealed: [], + version: 0, + } + views.set(id, minted) + return minted + } + // A navigation may carry new seed fields (the editor's in-place switch, + // a browser tab pointed at another URL); the record's identity and any + // plugin-side mutation (title/meta from updateTab) stay. + const patch: Partial = {} + if (params?.path !== undefined && params.path !== existing.tab.path) patch.path = params.path + if (params?.diff !== undefined) patch.diff = params.diff + if (params?.url !== undefined) { + const meta = typeof existing.tab.meta === 'object' && existing.tab.meta !== null + ? existing.tab.meta as Record + : {} + patch.meta = { ...meta, url: params.url } + } + if (existing.scope.cwd !== scope.cwd) { + views.set(id, { ...existing, scope, tab: { ...existing.tab, ...patch } }) + return views.get(id)! + } + if (Object.keys(patch).length === 0) return existing + const next: View = { ...existing, tab: { ...existing.tab, ...patch } } + views.set(id, next) + return next + }, + get: id => views.get(id), + has: id => views.has(id), + update(id, patch) { + const entry = views.get(id) + if (entry === undefined) return + put(id, { ...entry, tab: { ...entry.tab, ...patch } }) + }, + drop(id) { + if (views.delete(id)) notify() + }, + toggleExpanded(id, path) { + const entry = views.get(id) + if (entry === undefined) return + const expanded = entry.expanded.includes(path) + ? entry.expanded.filter(candidate => candidate !== path) + : [...entry.expanded, path] + put(id, { ...entry, expanded }) + }, + nextInstance(kind) { + const next = (instances.get(kind) ?? 0) + 1 + instances.set(kind, next) + return next + }, + versionOf: id => views.get(id)?.version ?? 0, + subscribe(listener) { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + } +} + +/** What a body registration injects (the plugin's business face). */ +export interface NativeBodyInjected { + readonly sessionId: string + readonly ctx: Context + readonly store: SidebarStore + readonly service: BetterSidebarService + readonly records: NativeTabRecords + /** The descriptor id this body draws (one registration per descriptor). */ + readonly descriptorId: string + /** + * Extra seed fields derived from the native record — a file address carries + * its path there, not in `navigation.params`. + */ + readonly paramsOf?: (info: NativeTabInfo) => NativeTabParams | undefined + /** + * The session the body acts in, when the record names one (a + * `session`-scoped file address names its own session); absent falls back to + * the session the slot is scoped to. + */ + readonly sessionIdOf?: (info: NativeTabInfo) => string | undefined +} + +/** The props the slot framework adds to every tab body and title. */ +export interface NativeBodyFrameworkProps { + readonly useTabInfo: () => NativeTabInfo +} + +/** Subscribe a component to its own record's mutations. */ +function useRecordVersion(records: NativeTabRecords, id: string): number { + return useSyncExternalStore( + listener => records.subscribe(listener), + () => records.versionOf(id), + ) +} + +/** The current session's workspace root, live from the client session list. */ +function useSessionCwd(ctx: Context, sessionId: string): string | undefined { + return useSyncExternalStore( + useMemo(() => (listener: () => void) => ctx.sessions.list.subscribe(listener), [ctx]), + () => ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd, + ) +} + +/** + * One plugin tab rendered inside the native right Sidebar: the descriptor's + * own component with the plugin's props, over a synthetic record minted from + * the native tab and dropped when the record ends. + */ +export function NativeTabBody(props: NativeBodyInjected & NativeBodyFrameworkProps): ReactNode { + const { ctx, store, service, records, descriptorId, useTabInfo } = props + const info = useTabInfo() + const nativeTab = info.tab + const version = useRecordVersion(records, nativeTab.id) + const sessionId = props.sessionIdOf?.(info) ?? props.sessionId + const cwd = useSessionCwd(ctx, sessionId) + const scope = useMemo((): SessionScope => ({ sessionId, cwd }), [sessionId, cwd]) + // `version` is not read: it only forces this render when the record changed. + void version + const derived = props.paramsOf?.(info) + const params = derived === undefined && nativeTab.navigation.params === undefined + ? undefined + : { ...derived, ...nativeTab.navigation.params } + const descriptor = service.getTab(descriptorId) + const view = records.ensure({ + id: nativeTab.id, + kind: nativeTab.kind, + title: nativeTab.title, + params, + scope, + mint: () => { + const state = store.getSnapshot().state + if (descriptor?.createTab === undefined || state === undefined) return undefined + const minted = descriptor.createTab(state) + return minted === null ? undefined : { title: minted.tab.title, meta: minted.tab.meta } + }, + }) + useEffect(() => () => { records.drop(nativeTab.id) }, [records, nativeTab.id]) + if (descriptor === undefined) { + // The orphaned fallback sits in the SAME native host as a live body, so + // it gets the same full-height box (its own root also relies on the + // `flex: 1` contract the column host restores). The host div carries + // `data-dsh-native-tab-host` (empty value) so the e2e lane can assert + // the fill, mirroring `data-dsh-better-sidebar`. + return createElement( + 'div', + { className: css.nativeTabHost, 'data-dsh-native-tab-host': '' }, + createElement(OrphanedTab, { ctx, store, scope, tab: view.tab, visible: nativeTab.visible }), + ) + } + return createElement( + RenderBoundary, + { className: css.tabBoundaryError }, + // The full-height host wrapper (see the `.nativeTabHost` rule in + // sidebar.module.css for the native `.paneBody` contract); its + // `data-dsh-native-tab-host` attribute lets the e2e lane assert the fill. + createElement( + 'div', + { className: css.nativeTabHost, 'data-dsh-native-tab-host': '' }, + createElement(descriptor.component, { + ctx, + store, + scope, + tab: view.tab, + visible: nativeTab.visible, + expanded: view.expanded, + revealed: view.revealed, + onToggleDir: (path: string) => { records.toggleExpanded(nativeTab.id, path) }, + onReferenceFile: (path: string, isDir: boolean) => { referenceInChat(ctx, sessionId, cwd, path, isDir) }, + onOpenDiff: (tab: SidebarTab) => { + service.openTab({ + type: 'diff', + title: tab.title, + id: tab.id, + ...(tab.diff === undefined ? {} : { diff: tab.diff }), + }, scope) + }, + onSubagentJump: (childSessionId: string) => { + service.openTab({ type: 'subagent', meta: { childSessionId } }, scope) + }, + }), + ), + ) +} + +/** What a title registration injects. */ +export interface NativeTitleInjected { + readonly records: NativeTabRecords +} + +/** + * A live tab chip: the synthetic record's title (the editor rewrites it on an + * in-place file switch, the side chat on the thread's first prompt). Without + * this registration the chip would keep the title captured when the tab + * opened. + */ +export function NativeTabTitle(props: NativeTitleInjected & NativeBodyFrameworkProps): ReactNode { + const { records, useTabInfo } = props + const nativeTab = useTabInfo().tab + return useSyncExternalStore( + listener => records.subscribe(listener), + () => records.get(nativeTab.id)?.tab.title ?? nativeTab.title, + ) +} + +/** The component pair one descriptor contributes to the native surface. */ +export interface NativeTabComponents { + readonly body: ComponentType + readonly title: ComponentType +} diff --git a/src/client/openpath-intercept.ts b/src/client/openpath-intercept.ts deleted file mode 100644 index f87e05d..0000000 --- a/src/client/openpath-intercept.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Interception of the chat's file-open funnel (DSH 0.1.2-alpha hosts). - * - * The alpha funnel is the Typert remote namespace `remote.session`: every - * chat-side file open (tool-row path links, the produced-files row, prose - * file mentions, inline-code paths) funnels through ChatView's injected - * `openFile`, which calls `ctx.remote.session.openWorkspacePath({ path })` - * with the path ALREADY resolved against the session cwd (verified against - * the DSH source: `packages/client/ui-chat/src/client/apply.ts` is the only - * production caller). The host side (`api/session-controller`) hands the - * path to the OS's default application (`openNativePath` → xdg-open on - * Linux). The pre-alpha funnel `ctx.workspaces.openPath` is gone — the - * alpha `IWorkspaces` has no openPath at all. - * - * `remote.session` is a cordis service (the gateway client's - * RemoteNamespaceService, key `remote.session`) whose methods are installed - * as ACCESSOR properties (`Object.defineProperty(service, method, { - * configurable: true, get })`) — the getter reads the method table at - * ACCESS time and returns the invocation closure. A plain assignment would - * not stick (no setter), so the wrapper shadows the method with a data - * property and restores the captured accessor descriptor on dispose. - * - * The namespace service appears asynchronously (the session-controller - * contribution mounts after the connection is up) and is disposed/recreated - * on contribution remounts; the caller therefore enters through - * `ctx.inject(['remote.session'], …)`, which re-fires on every remount. - * - * The wrapper is dependency-free by design (no React / ui-primitives), so - * the takeover logic is unit-testable and the file stays importable from - * the test runtime. - */ - -/** The one request shape the funnel method takes (mirror of the host's - * SessionOpenWorkspacePathRequest). */ -export interface OpenWorkspacePathRequest { - /** Absolute path (the chat caller already resolved it against the cwd). */ - path: string -} - -/** - * What the funnel method resolves to (mirror of the typert `RemoteResult` - * envelope): EVERY remote method folds its business value into - * `{ ok: true, value }` / `{ ok: false, error }` — the chat caller branches - * on `result.ok`, so a bare business value would be misread as a failure - * (`result.error.message` reads `message` off undefined). - */ -export type OpenWorkspacePathResult = - | { readonly ok: true; readonly value: { opened: boolean } } - | { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly details: object } } - -/** The namespace slice the wrapper shadows (mirror of the gateway client's - * RemoteNamespaceService for the `session` namespace). */ -export interface OpenWorkspacePathService { - openWorkspacePath(request: OpenWorkspacePathRequest, signal?: AbortSignal): Promise -} - -/** Per-call decisions the wrapper needs (wired to the store + ctx in the client half). */ -export interface OpenPathInterceptDeps { - /** - * Whether to take over this call: the `interceptOpenPath` pref AND the - * editor tab's own enable switch must both be on (an editor that cannot - * open must not swallow opens — they fall through to the Host). - */ - takeoverEnabled(): boolean - /** The session whose scope the sidebar editor loads the file in (current session). */ - currentSessionId(): string | undefined - /** Route the open into the sidebar editor (the established openSidebarFile). */ - openInSidebar(path: string, sessionId: string): void - /** Route a folder-reveal gesture ("Show in folder" passes '.') into the sidebar explorer. */ - revealInExplorer(path: string, sessionId: string): void -} - -/** - * Whether a path is the "Show in folder" folder-reveal gesture. The stock - * ui-deliverables row passes `'.'` (the session workspace root, resolved by - * the chat view to `"/."`); any path whose final segment is `.` is the - * same gesture. A directory has no editor content, so these opens must reach - * the explorer instead of an editor tab. - */ -export function isFolderRevealPath(path: string): boolean { - if (path === '.' || path === './') return true - const trimmed = path.replace(/[\\/]+$/, '') - return trimmed === '.' || /[\\/]\.$/.test(trimmed) -} - -/** - * Shadow `remote.session.openWorkspacePath`: intercepted calls open the file - * in the sidebar editor and resolve with the remote SUCCESS ENVELOPE - * (`{ ok: true, value: { opened: true } }`, so ChatView shows no error - * dialog); anything that declines falls through to the captured original - * closure (the host OS's default application) untouched. - * The one exception is the folder-reveal gesture, which is routed to - * {@link OpenPathInterceptDeps.revealInExplorer} instead. - * - * The original closure is captured by ACCESSING the accessor once at wrap - * time — it invokes whatever method records are mounted at that moment. If - * the contribution remounts its methods while the shadow is installed, the - * captured closure points at the old records; the session namespace is - * effectively permanent in practice, so this is accepted and the shadow is - * re-applied anyway when the remount recreates the service (the caller's - * `ctx.inject` re-fires). - * - * @param service - the `remote.session` namespace service. - * @param deps - per-call takeover decisions. - * @returns the disposer restoring the original accessor descriptor (HMR-safe). - */ -export function wrapOpenWorkspacePath( - service: OpenWorkspacePathService, - deps: OpenPathInterceptDeps, -): () => void { - const KEY = 'openWorkspacePath' - const target = service as object - const descriptor = Object.getOwnPropertyDescriptor(target, KEY) - // Invoke the accessor once to capture the CURRENT invocation closure. - const original = service.openWorkspacePath - // The gateway installs the namespace's whole method group in the same - // synchronous window as the service registration, so an inject-observed - // service always has the method — but a hand-rolled composition might not; - // wrapping nothing would turn fall-throughs into crashes, so decline. - if (typeof original !== 'function') return () => {} - const wrapped = (request: OpenWorkspacePathRequest, signal?: AbortSignal): Promise => { - if (deps.takeoverEnabled()) { - const sessionId = deps.currentSessionId() - if (sessionId !== undefined) { - if (isFolderRevealPath(request.path)) deps.revealInExplorer(request.path, sessionId) - else deps.openInSidebar(request.path, sessionId) - return Promise.resolve({ ok: true, value: { opened: true } }) - } - } - return original.call(service, request, signal) - } - Object.defineProperty(target, KEY, { - configurable: true, - enumerable: true, - writable: true, - value: wrapped, - }) - return () => { - // Restore the exact original property (the gateway's accessor) so a - // chain of wrappers keeps working across disposals in any order. - if (descriptor !== undefined) Object.defineProperty(target, KEY, descriptor) - else Reflect.deleteProperty(target, KEY) - } -} diff --git a/src/client/pinned.ts b/src/client/pinned.ts index 6ebdcb6..cc01b57 100644 --- a/src/client/pinned.ts +++ b/src/client/pinned.ts @@ -143,9 +143,8 @@ export function pinnedVisibleTo(tab: SidebarTab, viewer: PinnedViewer): boolean * Collect every pinned terminal visible to the viewer across ALL cached * session states. Excludes the viewer's own session (those tabs are on its * own strip). Order is stable: sessions in the cache's insertion order, - * tabs in tree order (splits → bottomSplits → floats) within each session - * — the order tabs were opened/pinned, so the rail never reorders between - * renders. + * tabs in tree order within each session — the order tabs were + * opened/pinned, so the rail never reorders between renders. */ export function collectPinnedTabs( bySession: ReadonlyMap, @@ -154,20 +153,14 @@ export function collectPinnedTabs( const entries: PinnedTabEntry[] = [] for (const [homeSessionId, state] of bySession) { if (homeSessionId === viewer.sessionId) continue - collectFromTree(state.splits, homeSessionId, viewer, entries) collectFromTree(state.bottomSplits, homeSessionId, viewer, entries) - for (const float of state.floats) { - if (float.tab.type === 'terminal' && pinnedVisibleTo(float.tab, viewer)) { - entries.push({ tab: float.tab, homeSessionId }) - } - } } return entries } /** Walk one split tree depth-first, collecting visible pinned terminals. */ function collectFromTree( - node: SidebarState['splits'], + node: SplitNode, homeSessionId: string, viewer: PinnedViewer, out: PinnedTabEntry[], diff --git a/src/client/prefs.ts b/src/client/prefs.ts index 082523a..4e6416f 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -12,7 +12,6 @@ import type { api } from './api.ts' import { clampTerminalFontSize, clampTitleBarStrip, - clampWidthPercent, SIDEBAR_PREFS_DEFAULTS, TITLE_BAR_SCHEMES, TITLE_BAR_STRIP_DEFAULT, @@ -26,7 +25,6 @@ export { TITLE_BAR_STRIP_DEFAULT, clampTerminalFontSize, clampTitleBarStrip, - clampWidthPercent, } export type { SidebarPrefs, TitleBarScheme } @@ -43,12 +41,6 @@ export function parsePrefs(value: unknown): SidebarPrefs { if (value === null || typeof value !== 'object') return { ...SIDEBAR_PREFS_DEFAULTS } const record = value as Record return { - openByDefault: typeof record.openByDefault === 'boolean' - ? record.openByDefault - : SIDEBAR_PREFS_DEFAULTS.openByDefault, - defaultWidthPercent: typeof record.defaultWidthPercent === 'number' && Number.isFinite(record.defaultWidthPercent) - ? clampWidthPercent(record.defaultWidthPercent) - : SIDEBAR_PREFS_DEFAULTS.defaultWidthPercent, autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean' ? record.autoOpenSubagent : SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent, @@ -76,15 +68,9 @@ export function parsePrefs(value: unknown): SidebarPrefs { terminalFontSize: typeof record.terminalFontSize === 'number' && Number.isFinite(record.terminalFontSize) ? clampTerminalFontSize(record.terminalFontSize) : SIDEBAR_PREFS_DEFAULTS.terminalFontSize, - interceptOpenPath: typeof record.interceptOpenPath === 'boolean' - ? record.interceptOpenPath - : SIDEBAR_PREFS_DEFAULTS.interceptOpenPath, editorExplorer: typeof record.editorExplorer === 'boolean' ? record.editorExplorer : SIDEBAR_PREFS_DEFAULTS.editorExplorer, - changesDiffFloat: typeof record.changesDiffFloat === 'boolean' - ? record.changesDiffFloat - : SIDEBAR_PREFS_DEFAULTS.changesDiffFloat, workspaceFence: typeof record.workspaceFence === 'boolean' ? record.workspaceFence : SIDEBAR_PREFS_DEFAULTS.workspaceFence, diff --git a/src/client/reference-in-chat.ts b/src/client/reference-in-chat.ts new file mode 100644 index 0000000..1043457 --- /dev/null +++ b/src/client/reference-in-chat.ts @@ -0,0 +1,42 @@ +/** + * The explorer's `@`-reference button: insert one file or folder reference + * into the conversation draft of a session. + * + * Directories append the folder mention (`@dir/`) as plain text so DSH's + * folder decoration and completion keep working; files insert a structured + * chip like the native `@` picker, so the whole reference stays one link + * instead of decorating only the leading folder. The session-scope context and + * the conversation input service are resolved at click time; a missing service + * or scope degrades to a logged no-op, never a crash. + * + * Shared by the plugin's own panel and the native right-Sidebar tab body, so + * both surfaces behave identically. + */ +import type { Context } from '../context-types.ts' +import { appendToDraft, insertFileReference } from './conversation-draft.ts' +import { relativeTo } from './paths.ts' + +/** + * Reference one path in the session's conversation draft. + * @param ctx - the client context. + * @param sessionId - the session whose draft receives the reference. + * @param cwd - that session's workspace root (for the relative spelling). + * @param path - the absolute or workspace-relative path to reference. + * @param isDir - whether the path is a directory (folder mention vs file chip). + */ +export function referenceInChat( + ctx: Context, + sessionId: string, + cwd: string | undefined, + path: string, + isDir: boolean, +): void { + const rel = relativeTo(cwd ?? '', path) + if (isDir) { + appendToDraft(ctx, sessionId, `@${rel === '.' ? './' : `${rel}/`}`) + return + } + if (!insertFileReference(ctx, sessionId, rel)) { + appendToDraft(ctx, sessionId, `@${rel}`) + } +} diff --git a/src/client/resource-address.ts b/src/client/resource-address.ts new file mode 100644 index 0000000..6d689e9 --- /dev/null +++ b/src/client/resource-address.ts @@ -0,0 +1,150 @@ +/** + * The DSH resource-address grammar for files (`dsh-resource://file/…`). + * + * DSH's right Sidebar opens content by ADDRESS, and a file's address IS the + * tab's content identity: the same address is the same tab. Two scopes exist: + * + * - `dsh-resource://file/session//` names a file by its path + * relative to that session's workspace root OR by its absolute path kept + * absolute inside the session scope (`src/a.ts` vs `/outside/a.ts` — the + * leading `/` survives; the host resolves it against the root it holds for + * the session); + * - `dsh-resource://file/absolute/` names a file by its absolute path + * with the leading `/` dropped (`absolute/home/me/x.txt`; Windows + * `absolute/C:/x/y.txt`; a UNC path keeps an empty first segment, + * `absolute//server/share/x.txt`). It carries no session. `fileAddressFor` + * no longer produces this scope on DSH 0.1.5-alpha.2, but it must keep + * parsing: legacy and third-party addresses still spell it. + * + * Every id and path segment is component-encoded, so a name carrying `#`, `?` + * or a space survives the round trip; `:` stays literal so a drive letter + * reads as written. + * + * The plugin parses these addresses itself instead of importing + * `@deepseek-ai/dsh-util-workspace-path`: the client bundle's purity gate + * forbids value-importing an unlisted `@deepseek-ai/*` package. This module + * mirrors that package's implementation — `packages/util/workspace-path/src/file-address.ts` + * and `fileAddressFor` in `packages/util/workspace-path/src/index.ts` in + * DSH 0.1.5-alpha.2 (github.com/deepseek-ai/deepseek-harness, tag + * `dsh-v0.1.5-alpha.2`) — and is pinned by tests/resource-address.spec.ts. + */ + +/** The scheme and type every file address opens with. */ +export const FILE_ADDRESS_PREFIX = 'dsh-resource://file/' + +/** + * A file resource address, in one of two scopes. + */ +export type FileAddress = + | { + readonly scope: 'session' + /** The session whose workspace root resolves the path. */ + readonly sessionId: string + /** Absolute or workspace-relative `/`-separated path; empty for the workspace root itself. */ + readonly path: string + } + | { + readonly scope: 'absolute' + /** Absolute `/`-separated path: `/a/b` on POSIX, `C:/a/b` for a drive, `//server/share/a` for UNC. */ + readonly path: string + } + +/** Component-encode one id or path segment, keeping `:` literal for drive letters. */ +function encodeSegment(segment: string): string { + return encodeURIComponent(segment).replace(/%3A/gi, ':') +} + +/** Encode a `/`-separated path segment by segment. */ +function encodePath(path: string): string { + return path.split('/').map(encodeSegment).join('/') +} + +/** Whether a decoded first path segment is a Windows drive (`C:`). */ +function isDriveSegment(segment: string | undefined): boolean { + return segment !== undefined && /^[A-Za-z]:$/.test(segment) +} + +/** + * Build the address of a file read through one session. + * @param sessionId - the session whose workspace root resolves the path. + * @param path - absolute or workspace-relative path; backslashes are normalized + * to `/`, and leading `./` prefixes are dropped (a leading `/` is KEPT: an + * absolute path stays absolute inside the session scope). + * @returns the `dsh-resource://file/session//` address. + */ +export function sessionFileAddress(sessionId: string, path: string): string { + const normalized = path.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '') + return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(normalized)}` +} + +/** + * Build the address of a file by its absolute path. + * @param path - absolute path; backslashes are normalized to `/` and the leading `/` + * is dropped, except that a UNC path keeps one empty first segment. + * @returns the `dsh-resource://file/absolute/` address. + */ +export function absoluteFileAddress(path: string): string { + const normalized = path.replace(/\\/g, '/') + const unc = normalized.startsWith('//') + const absolute = normalized.replace(/^\/+/, '') + return `${FILE_ADDRESS_PREFIX}absolute/${unc ? '/' : ''}${encodePath(absolute)}` +} + +/** + * Read a file address back into its parts without resolving `.` or `..`. + * Query and fragment suffixes are ignored; encoded path segments are decoded. + * @param address - a candidate address. + * @returns the parts, or `undefined` when the string is not a + * `dsh-resource://file/` URI in a known scope with a path, or a segment is + * not validly encoded. + */ +export function parseFileAddress(address: string): FileAddress | undefined { + try { + if (!address.startsWith(FILE_ADDRESS_PREFIX)) return undefined + const end = address.search(/[?#]/) + const [scope, ...rest] = address.slice(FILE_ADDRESS_PREFIX.length, end === -1 ? undefined : end).split('/') + if (scope === 'session') { + const [id, ...segments] = rest + if (id === undefined || id === '' || segments.length === 0) return undefined + return { scope, sessionId: decodeURIComponent(id), path: segments.map(decodeURIComponent).join('/') } + } + if (scope === 'absolute') { + // An empty first segment with more behind it is a UNC path's `//`; alone it is no path. + const unc = rest[0] === '' && rest.length > 1 + const segments = (unc ? rest.slice(1) : rest).map(decodeURIComponent) + if (segments.length === 0 || segments[0] === '') return undefined + if (unc) return { scope, path: `//${segments.join('/')}` } + return { scope, path: isDriveSegment(segments[0]) ? segments.join('/') : `/${segments.join('/')}` } + } + return undefined + } catch { + // `decodeURIComponent` throws URIError on a malformed escape. + return undefined + } +} + +/** Whether a path is absolute in either spelling the host accepts. */ +function isAbsolutePath(path: string): boolean { + return path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\') +} + +/** + * The address for a path as a caller holds it: ALWAYS session-scoped. A + * relative path, or an absolute path inside the session's workspace, becomes + * the relative spelling; an absolute path outside it, or one whose workspace + * root is unknown, keeps its absolute path in that session's address. (Mirror + * of `fileAddressFor` — on alpha.2 this helper no longer produces the + * `absolute` scope, though such addresses still parse.) + * @param sessionId - the session the path is read in. + * @param cwd - that session's workspace root, when known. + * @param path - absolute or workspace-relative path, either separator spelling. + * @returns the `dsh-resource://file/…` address. + */ +export function fileAddressFor(sessionId: string, cwd: string | undefined, path: string): string { + const normalized = path.replace(/\\/g, '/') + if (!isAbsolutePath(normalized)) return sessionFileAddress(sessionId, normalized) + const root = cwd === undefined ? '' : cwd.replace(/\\/g, '/').replace(/\/+$/, '') + if (root !== '' && normalized === root) return sessionFileAddress(sessionId, '') + if (root !== '' && normalized.startsWith(`${root}/`)) return sessionFileAddress(sessionId, normalized.slice(root.length + 1)) + return sessionFileAddress(sessionId, normalized) +} diff --git a/src/client/service.ts b/src/client/service.ts index 8897e7d..1df73a5 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -22,11 +22,10 @@ import type { ReactNode } from 'react' import type { Context } from '../context-types.ts' import { - activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, closeFloatByTab, floatWithTab, - leafWithTab, openTabInActivePane, patchTab, raiseFloat, tabOpenIn, togglePanel, treeOf, - type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab, + activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, + leafWithTab, openTabInBottomPane, patchTab, tabOpenIn, + type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab, type TabType, } from './state.ts' -import { isNarrowWidth } from './breakpoints.ts' import { extOf } from './paths.ts' import type { SessionScope } from './api.ts' import type { SidebarPrefs } from '../prefs-shared.ts' @@ -163,6 +162,17 @@ export interface TabDescriptor { /** Unique id; also the `SidebarTab.type` value (`'explorer'`, `'my-plugin:db'`). */ id: string title: string | (() => string) + /** + * One-line description of what this tab shows, rendered under the title in + * the host's new-tab list (DSH's native right Sidebar guide page). DSH + * 0.1.5-rc.1 renders descriptions only while the guide lists at most 4 + * entries — a longer list drops every description and shows titles alone — + * and a descriptor that declares none renders the title by itself (the + * host no longer substitutes a generic fallback, so declare the real + * purpose of the page). Evaluated at render time, so a function follows + * the active locale. + */ + description?: string | (() => string) icon?: ReactNode | ((size: number) => ReactNode) /** + menu sort order (ascending); default 100. */ order?: number @@ -339,6 +349,57 @@ export interface OpenTabSeed { url?: string /** JSON-serializable custom state carried on the minted tab (persisted across reloads; v0.12.0+). */ meta?: unknown + /** + * Where the open lands. `'right'` (the default) is DSH's right Sidebar — + * the plugin's content is registered there as native tab types; `'bottom'` + * is the plugin's own bottom workbench. Only the plugin's own flows pass + * `'bottom'` (the bottom panel's + menu, the auto-terminal). + */ + target?: 'right' | 'bottom' +} + +/** + * The plugin-side seed a native right-Sidebar tab carries in its navigation + * params (the native surface passes them back on every navigation). + */ +export interface NativeTabParams { + /** Overrides the descriptor's title for this instance. */ + title?: string + /** A file path (the editor window's content seed). */ + path?: string + /** A URL the tab navigates to on mount (the browser tab's seed). */ + url?: string + /** A diff reference (the diff tab's content seed). */ + diff?: SidebarTab['diff'] + /** JSON-serializable custom state carried on the synthetic record. */ + meta?: unknown +} + +/** + * The plugin's write face over DSH's native right Sidebar. + * + * Installed by the client half ({@link ./native/surface.ts}) so the service — + * and therefore every consumer of `ctx.betterSidebar` — keeps speaking the + * plugin's own vocabulary while the content lands natively. Without it the + * service writes into the plugin's own layout (the pre-0.1.5 behavior, which + * the bottom workbench still uses). + * @internal Not part of the consumer contract. + */ +export interface SidebarSurface { + /** Open a page type in one session's native surface. */ + openTab(input: { sessionId: string; kind: string; params: NativeTabParams; revealIfOpened: boolean }): void + /** Open a resource address in one session's native surface. */ + openResource(input: { sessionId: string; address: string; line?: number; revealIfOpened: boolean }): void + /** The file address of one path (the native surface owns the grammar). */ + fileAddress(sessionId: string, cwd: string | undefined, path: string): string + /** Close one native tab; the closed record's type/title, or undefined when the id is not native. */ + close(sessionId: string, tabId: string): { type: string; title: string } | undefined + /** Patch a native tab's plugin-side record; false when it is not native. */ + update(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): boolean + /** Focus a native tab; false when it is not native. */ + activate(tabId: string): boolean + /** Whether a tab id belongs to the native surface. */ + has(tabId: string): boolean } /** @@ -379,12 +440,10 @@ export interface BetterSidebarService { * without switching the UI's active session; when absent the open lands * in the currently active session (the pre-0.12 behavior). * - * A CONTENT open (a `path` or `url` seed) must land in sight: when the - * panel hosting the landing pane is collapsed, it is expanded - * automatically (the right panel by default, the bottom panel when the - * active pane lives there; on narrow viewports the merged drawer opens). - * Type-only opens (the + menu, agent-terminal auto-tabs) never expand — - * the panel behavior is their caller's business. + * Every open lands in the bottom workbench and expands it (the workbench + * is the plugin's only own surface; the right column is DSH's native + * Sidebar). An open carrying a `path` or `url` goes through the native + * surface instead, which never touches this state. * * Note: `available` gates the + menu's disabled state only — it does NOT * refuse `openTab` (only the settings disable switch does). @@ -426,6 +485,11 @@ export interface BetterSidebarService { activateTab(tabId: string, scope?: SessionScope): void /** Open a file in the sidebar editor of `scope`'s session (title defaults to the file name). */ openFile(scope: SessionScope, path: string, title?: string): void + /** + * Install (or clear) the native right-Sidebar write face. + * @internal Called once by the client half; not part of the consumer API. + */ + setSurface(surface: SidebarSurface | undefined): void } /** The file name of a path (both separators). */ @@ -465,7 +529,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes * The plugin version this service instance reports. Keep in lockstep with * `package.json`'s version — `tests/service.spec.ts` asserts the pair. */ -export const SIDEBAR_SERVICE_VERSION = '0.18.1' +export const SIDEBAR_SERVICE_VERSION = '0.19.0' /** * Monotonic capability list consumers use to gate new API usage (features @@ -480,9 +544,10 @@ export const SIDEBAR_SERVICE_VERSION = '0.18.1' * - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render * - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims) * - 'settingSelect': SidebarSettingToggle type 'select' (options/multi) - * - 'floatWindows' (v0.16.0): tabs float as free windows — openTab's dedupe/ - * id focus targets RAISE the floating window (never duplicate the tab or - * expand panels), closeTab on a floating tab closes it with its window. + * + * v0.19.0 REMOVED 'floatWindows': the free-window feature is gone (DSH 0.1.5 + * owns the right column, so the plugin keeps only its bottom workbench). + * Consumers must not gate on it any more. */ export const SIDEBAR_FEATURES = [ 'badge', @@ -495,7 +560,6 @@ export const SIDEBAR_FEATURES = [ 'pluginSettings', 'urlTarget', 'settingSelect', - 'floatWindows', ] as const /** Run one plugin callback; a throw is logged and never breaks the caller. */ @@ -516,6 +580,8 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe const tabs = new Map() const viewers = new Map() const listeners = new Set<() => void>() + /** The native right-Sidebar write face, installed by the client half. */ + let surface: SidebarSurface | undefined const notify = (): void => { for (const fn of [...listeners]) fn() @@ -608,6 +674,61 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe const targetSessionId = scope?.sessionId ?? store.getSnapshot().sessionId if (targetSessionId === undefined) return const callbackScope: SessionScope = scope ?? { sessionId: targetSessionId } + // ── Native right Sidebar ────────────────────────────────────────────── + // With the native surface installed, every open except an explicit + // bottom-panel one lands there: a file path becomes a resource address + // (the native registry routes it to the plugin's file type), a path-less + // editor open becomes the `files` page kind, and everything else becomes + // a page open carrying the seed as navigation params. + if (surface !== undefined && seed.target !== 'bottom') { + const state = store.getSnapshot().state + // The descriptor's own factory mints what a view needs beyond the seed: + // the side chat's thread bootstrap / reattach meta, the terminal's + // per-instance title. A `null` return refuses the open (terminal cap). + const minted = descriptor.createTab === undefined || state === undefined + ? undefined + : descriptor.createTab(state) + if (minted === null) return + const title = seed.title ?? minted?.tab.title + ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title) + // Multi-instance kinds (terminal / browser / side chat / diff) mint a + // fresh tab per open; single-instance kinds focus the existing one. + const revealIfOpened = descriptor.createTab === undefined + const synthetic: SidebarTab = { + id: seed.id ?? minted?.tab.id ?? seed.type, + type: seed.type, + title, + ...(seed.path === undefined ? {} : { path: seed.path }), + ...(seed.diff === undefined ? {} : { diff: seed.diff }), + ...(seed.meta === undefined && minted?.tab.meta === undefined ? {} : { meta: seed.meta ?? minted?.tab.meta }), + } + if (seed.path !== undefined) { + surface.openResource({ + sessionId: targetSessionId, + address: surface.fileAddress(targetSessionId, scope?.cwd, seed.path), + revealIfOpened: true, + }) + } else if (seed.type === 'editor') { + // The path-less editor window IS the file explorer. + surface.openTab({ sessionId: targetSessionId, kind: 'files', params: {}, revealIfOpened: true }) + } else { + surface.openTab({ + sessionId: targetSessionId, + kind: seed.type, + params: { + title, + ...(seed.url === undefined ? {} : { url: seed.url }), + ...(seed.diff === undefined ? {} : { diff: seed.diff }), + ...(synthetic.meta === undefined ? {} : { meta: synthetic.meta }), + }, + revealIfOpened, + }) + } + // The native surface reports one open event, not create-vs-focus, so a + // lifecycle consumer hears onOpen (documented in the guide). + safeCall(() => descriptor.onOpen?.(synthetic, callbackScope)) + return + } // Whether this open targets a session that is NOT the one on screen: a // targeted open must not auto-expand panels the user cannot see (the // expansion is about landing "in sight" for the CURRENT viewer). @@ -617,6 +738,10 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe // dedupe/id-safety-net focus is an ACTIVATION, not an open). let created: SidebarTab | undefined let activated: SidebarTab | undefined + // A bottom-targeted open lands in the bottom workbench's own pane; the + // right tree it would otherwise follow is no longer rendered (DSH's + // native sidebar owns the right column). + const land = openTabInBottomPane const reducer = (state: SidebarState): SidebarState => { // Let the descriptor mint the tab (terminal's nextTerminal bump, etc.). let tab: SidebarTab @@ -625,7 +750,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe const result = descriptor.createTab(state) if (result === null) return state tab = result.tab - next = applyDedupe(state, result.tab, descriptor) + next = applyDedupe(state, result.tab, descriptor, land) if (result.patch !== undefined) next = { ...next, ...result.patch } } else { tab = { @@ -638,7 +763,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe ...(seed.diff !== undefined ? { diff: seed.diff } : {}), ...(seed.meta !== undefined ? { meta: seed.meta } : {}), } - next = applyDedupe(state, tab, descriptor) + next = applyDedupe(state, tab, descriptor, land) } // Classify the landing against the INPUT state FIRST: a FOCUS fires // onActivate with the tab that is active NOW; a real creation fires @@ -649,8 +774,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe // tab that never closes. const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined) const key = dedupeKey?.(tab) - const inputTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs) - .concat(state.floats.map(f => f.tab)) + const inputTabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs) const existedByKey = key !== undefined && inputTabs.some(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key) const existedById = tabOpenIn(state, tab.id) @@ -670,57 +794,23 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe if (isCreation) { // Resolve the ACTUAL landed tab — the url patch mints a new object, // so the callback must see the tab that was really inserted. - const landedTabs = allLeaves(landed.splits).concat(allLeaves(landed.bottomSplits)).flatMap(leaf => leaf.tabs) + const landedTabs = allLeaves(landed.bottomSplits).flatMap(leaf => leaf.tabs) created = landedTabs.find(candidate => candidate.id === tab.id) ?? tab } else { // A focus happened: resolve the tab that is actually active now and // report THAT to onActivate (never the caller's un-inserted seed). - // The pool covers free windows too — a floating instance focuses by - // raising, and the callback must see the real (patched) tab. - const candidates = allLeaves(landed.splits).concat(allLeaves(landed.bottomSplits)).flatMap(leaf => leaf.tabs) - .concat(landed.floats.map(f => f.tab)) + const candidates = allLeaves(landed.bottomSplits).flatMap(leaf => leaf.tabs) activated = key !== undefined ? candidates.find(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key) : candidates.find(candidate => candidate.id === tab.id) activated ??= tab } - // A CONTENT open that focuses an existing FLOATING tab is already in - // sight (free windows render regardless of panel state): expanding a - // panel for it would point the user at a pane the content is not in. - if ( - !isCreation - && floatWithTab(landed, activated?.id ?? tab.id) !== undefined - ) { - return landed - } - // A CONTENT open (file / browser) must land in sight: when the panel - // hosting the landing pane is collapsed, expand it. On narrow - // viewports the two workbenches merge into one drawer, so the drawer - // (panelOpen) is the only lever; on wide viewports the landing pane's - // own panel opens — the bottom panel when the active pane lives in the - // bottom tree, else the right panel. Type-only opens (+ menu, - // agent-terminal auto-tabs) never expand (the panel behavior is their - // caller's business). The check runs on the post-dedupe state, so a - // content open that merely FOCUSES an existing tab expands the panel - // too — the open must never land out of sight. Opens targeted at an - // INACTIVE session never expand (nothing is in sight for the user). - if ( - !targetsInactiveSession - && typeof window !== 'undefined' - && (seed.path !== undefined || seed.url !== undefined) - ) { - if (isNarrowWidth(window.innerWidth)) { - if (!landed.panelOpen) return togglePanel(landed) - } else { - const hostKey = treeOf(landed, landed.activePane ?? '') - if (hostKey === 'bottomSplits') { - if (!landed.bottomOpen) return { ...landed, bottomOpen: true } - } else if (!landed.panelOpen) { - return togglePanel(landed) - } - } - } - return landed + // Every open that lands in the workbench is visible: a creation + // expands it (openTabInBottomPane) and so does a FOCUS (the dedupe/id + // reducers only activate the tab). An open targeted at an INACTIVE + // session expands THAT session's workbench — it is waiting for the + // user when they switch to it. + return landed.bottomOpen ? landed : { ...landed, bottomOpen: true } } // A scope targeting ANOTHER session lands the open there without // switching the UI; a scope naming the active session (or no scope) @@ -735,19 +825,27 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe } const closeTab = (tabId: string, scope?: SessionScope): void => { + const sessionId = scope?.sessionId ?? store.getSnapshot().sessionId + if (surface !== undefined && sessionId !== undefined) { + const closedNative = surface.close(sessionId, tabId) + if (closedNative !== undefined) { + const descriptor = tabs.get(closedNative.type) + if (descriptor !== undefined) { + safeCall(() => descriptor.onClose?.( + { id: tabId, type: closedNative.type as TabType, title: closedNative.title }, + scope ?? { sessionId }, + )) + } + return + } + } let closed: SidebarTab | undefined store.reduce((state) => { // Unknown tab ids are a strict no-op: no state churn, no notify, no // pointless localStorage rewrite (mirrors updateTab's short-circuit). if (!tabOpenIn(state, tabId)) return state - // A floating tab closes WITH its window — the float is the tab's pane. - const float = floatWithTab(state, tabId) - if (float !== undefined) { - closed = float.tab - return closeFloatByTab(state, tabId) - } const paneId = findPaneIdOf(state, tabId) - const leaf = leafWithTab(state[treeOf(state, paneId)], tabId) + const leaf = leafWithTab(state.bottomSplits, tabId) closed = leaf?.tabs.find(tab => tab.id === tabId) return closeTabReducer(state, paneId, tabId) }) @@ -769,6 +867,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe /** Patch an open tab's display fields (a missing tab id is a no-op). */ const updateTab = (tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void => { + if (surface?.update(tabId, patch) === true) return store.reduce((state) => patchTab(state, tabId, { ...(patch.title !== undefined ? { title: patch.title } : {}), ...(patch.path !== undefined ? { path: patch.path } : {}), @@ -778,18 +877,13 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe /** Activate an open tab (the tab-bar activation path; fires onActivate). */ const activateTab = (tabId: string, scope?: SessionScope): void => { + if (surface?.activate(tabId) === true) return let activated: SidebarTab | undefined store.reduce((state) => { // Unknown tab ids are a strict no-op (no state churn / notify). if (!tabOpenIn(state, tabId)) return state - // A floating tab "activates" by raising its window — no pane switch. - const float = floatWithTab(state, tabId) - if (float !== undefined) { - activated = float.tab - return raiseFloat(state, float.id) - } const paneId = findPaneIdOf(state, tabId) - const leaf = leafWithTab(state[treeOf(state, paneId)], tabId) + const leaf = leafWithTab(state.bottomSplits, tabId) activated = leaf?.tabs.find(tab => tab.id === tabId) return activateTabReducer(state, paneId, tabId) }) @@ -829,37 +923,36 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe updateTab, activateTab, openFile, + setSurface: (next: SidebarSurface | undefined) => { surface = next }, } } /** * Apply dedup: if a tab whose `dedupeKey` matches an existing tab of the - * same type exists, focus it; otherwise land the tab through - * `openTabInActivePane` (the id safety net + active-pane landing are that - * reducer's job — not re-implemented here). + * same type exists, focus it; otherwise land the tab through `land` (the id + * safety net + landing are that reducer's job — not re-implemented here). * `single: true` resolves to the id-key sugar when no explicit key is given. */ -function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescriptor): SidebarState { +function applyDedupe( + state: SidebarState, + tab: SidebarTab, + descriptor: TabDescriptor, + land: (state: SidebarState, tab: SidebarTab) => SidebarState = openTabInBottomPane, +): SidebarState { const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined) const key = dedupeKey?.(tab) if (key !== undefined) { - // The scan covers BOTH trees: opening a single-instance tab from the - // bottom panel focuses an existing instance wherever it lives. - for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) { + for (const leaf of allLeaves(state.bottomSplits)) { const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key) if (existing !== undefined) return activateTabReducer(state, leaf.id, existing.id) } - // A floating instance focuses by raising its window (no duplicate tab, - // no panel expansion — the window is the tab's pane). - const floated = state.floats.find(f => f.tab.type === tab.type && dedupeKey!(f.tab) === key) - if (floated !== undefined) return raiseFloat(state, floated.id) } - return openTabInActivePane(state, tab) + return land(state, tab) } -/** Find which pane hosts a tab id ('' if none). Either tree is searched. */ +/** Find which pane hosts a tab id ('' if none). */ function findPaneIdOf(state: SidebarState, tabId: string): string { - for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) { + for (const leaf of allLeaves(state.bottomSplits)) { if (leaf.tabs.some(t => t.id === tabId)) return leaf.id } return state.activePane ?? '' diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index a287af2..b3b296a 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -69,41 +69,6 @@ left: 0; } -/* The persistent expand/collapse cluster at the top-right corner: two rail - controls side by side (bottom panel's glyph LEFT of the right panel's). - Always pinned to the viewport corner — inside the right panel's top-right - while it is open, sitting flush in the 34px tab strip (28px buttons at - top: 3) whose right end it really squeezes. Host-internal z-index 45 - (above the panels' 40); the host's global layer is z-index 25, so the - whole cluster stays below DSH's ui-cordis dynamic-plugin panel (30) and - the app's overlay stack (menus/tooltips/modals sit at 100+), so a cordis - approval popup is never hidden behind the workbench (issue #52). Absolute - within the panel host (the host's inset: 0 makes viewport coordinates - identical). */ -.toggleCluster { - position: absolute; - /* env() resolves to 0 on devices without a top inset (desktops). */ - top: calc(3px + env(safe-area-inset-top)); - right: 10px; - z-index: 45; - display: flex; - flex-direction: row; - gap: 4px; - pointer-events: auto; - /* The cluster recenters between the panel's tab strip (top: 3) and DSH's - session header row (top: 14, see the collapsed rule below) on every - expand/collapse — ride the panel slide's own duration/easing so the two - moves stay in lockstep instead of the cluster snapping. */ - transition: top var(--ds-transition-duration-slow) var(--ds-ease-in-out); -} - -/* The tab strip of the OPEN right panel reserves the cluster's width at its - right end, so the tabs and the + menu genuinely yield space to the cluster - (never hiding under it). */ -.panel:not(.panelHidden) .tabBar { - padding-right: 72px; -} - /* The closed-state affordance is the native rail icon control: a plain 28px circle (the app's icon-button size — the cluster must stay inside the 34px tab-strip band so it never covers the pane headers below), @@ -136,75 +101,6 @@ cursor: default; } -.panel { - box-sizing: border-box; - position: absolute; - top: 0; - right: 0; - /* Full height: the bottom panel squeezes only the center column, so the - right panel never gives up any vertical position. */ - bottom: 0; - /* Host-internal z-index 40 (sibling of the bottom panel's 40; the two - never overlap — the bottom panel's right edge is this panel's left - edge — and both sit below the toggle cluster's 45). The absolute - global layer comes from the host ([data-dsh-panel-host], 25) — the - whole stack stays above the app's in-flow content and below DSH's - ui-cordis dynamic-plugin panel (30) and the app's overlay stack (100+), - so menus/tooltips/modals always win over the panels (issue #52). The - corner handle is a child and stacks within the panel. */ - z-index: 40; - display: flex; - flex-direction: column; - /* Re-enable interaction inside the click-transparent panel host. */ - pointer-events: auto; - /* The generic card surface (see the root contract above). Never - `--dsw-specific-sidebar-fill` — skin plugins restyle that token for the - app's own left nav and a panel consuming it loses its fill or clashes - with labels (#60/#105/#106). */ - background: var(--dsw-alias-bg-layer-1); - border-left: 1px solid var(--dsw-alias-border-l2); - /* Bottom safe-area (notched phones): the drawer's bottom content clears - the home indicator; 0 on desktops. */ - padding-bottom: env(safe-area-inset-bottom); - /* Expand/collapse slides the panel; fullscreen animates the width. */ - transition: - transform var(--ds-transition-duration-slow) var(--ds-ease-in-out), - width var(--ds-transition-duration-slow) var(--ds-ease-in-out); -} - -/* Collapsed: slid off-screen, interactive nothing, hidden after the slide. */ -.panelHidden { - transform: translateX(102%); - pointer-events: none; - visibility: hidden; - transition: - transform var(--ds-transition-duration-slow) var(--ds-ease-in-out), - width var(--ds-transition-duration-slow) var(--ds-ease-in-out), - visibility 0s linear var(--ds-transition-duration-slow); -} - -/* Width drags write at pointer cadence; easing would detach the edge. */ -.panel[data-dragging] { - transition: none; -} - -.panelResize { - position: absolute; - /* Centered on the panel edge (an 8px hit strip straddling the border, the - same pattern as the host's own frame handles). */ - left: -4px; - top: 0; - bottom: 0; - width: 8px; - cursor: col-resize; - z-index: 2; - touch-action: none; -} - -.panelResizeActive { - background: var(--dsw-alias-interactive-bg-hover-accent); -} - .panelBody { flex: 1; min-height: 0; @@ -263,7 +159,6 @@ will-change: transform is only live while a drag is (the body attribute toggles with the drag state), promoting the panels to their own layer without paying the compositing cost at rest. */ -.panel, .bottomPanel { contain: layout style; } @@ -324,148 +219,6 @@ body[data-dsh-sidebar-dragging] .bottomPanel { padding-right: 40px; } -/* ── Free windows ──────────────────────────────────────────────────────── */ - -/* One floating tab: the same card surface as the panels, at viewport - coordinates inside the panel host (immune to shell transforms). z-index - 42: above the panels (40) so a window drags across them, below the - toggle cluster (45) and the app's overlay stack (100+, issue #52). - Stacking among windows is the floats array order (siblings share the - z-index; DOM order wins). Deliberately NO box transition — the header/ - resize drags write left/top/width/height per frame. Elevated surface, - so unlike the flat panels it earns a shadow token. */ -.floatWindow { - position: absolute; - z-index: 42; - display: flex; - flex-direction: column; - /* Re-enable interaction inside the click-transparent panel host. */ - pointer-events: auto; - background: var(--dsw-alias-bg-layer-1); - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 8px; - box-shadow: var(--dsw-shadow-lv3); - overflow: hidden; - contain: layout style; -} - -.floatWindowDragging { - will-change: left, top, width, height; -} - -/* The header: the move/dock-back drag handle, mirroring the tab strip's - chrome (34px, hairline bottom, secondary label). */ -.floatHeader { - flex: none; - display: flex; - align-items: center; - gap: 4px; - height: 34px; - padding: 0 4px 0 10px; - font: var(--dsw-font-xxs-12); - color: var(--dsw-alias-label-secondary); - background: var(--dsw-alias-bg-layer-1); - border-bottom: 1px solid var(--dsw-alias-border-l1); - cursor: grab; - user-select: none; -} - -.floatWindowDragging .floatHeader { - cursor: grabbing; -} - -.floatTitle { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Mirrors .tabClose. */ -.floatClose { - display: inline-flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - padding: 0; - border: none; - border-radius: 4px; - background: transparent; - color: var(--dsw-alias-label-tertiary); - cursor: pointer; - flex: none; -} - -.floatClose:hover { - background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-primary); -} - -/* Mirrors .paneContent's column-flex contract: the tab cell must hand its - child a definite WIDTH via align-stretch. A row flex (the default) sizes - the main axis (width) from content — intrinsic-zero hosts (e.g. a plugin - tab UI) collapse to a 0px-wide sliver and paint nothing (the blank - whale-report window). */ -.floatContent { - flex: 1; - min-height: 0; - min-width: 0; - display: flex; - flex-direction: column; - overflow: hidden; -} - -/* The SE resize corner (mirror of .cornerHandle). */ -.floatResize { - position: absolute; - right: 0; - bottom: 0; - width: 14px; - height: 14px; - z-index: 2; - cursor: nwse-resize; - touch-action: none; -} - -.floatResize:hover { - background: var(--dsw-alias-interactive-bg-hover-accent); -} - -/* A pane highlighted as the dock target while a float header drag hovers it - (the attribute is written directly by the drag at pointer cadence; - skin-addressable via [data-dsh-pane][data-dsh-float-dock-over]). */ -.pane[data-dsh-float-dock-over] { - outline: 2px dashed var(--dsw-alias-interactive-bg-hover-accent); - outline-offset: -2px; -} - -/* The drag-out hint over the conversation column: a dashed frame marking - the drop zone, pointer-transparent so it never disturbs the drag it - describes. z-index 46: above the windows (42) for unambiguous feedback, - still below the app's overlay stack. */ -.floatDropHint { - position: absolute; - z-index: 46; - pointer-events: none; - border: 2px dashed var(--dsw-alias-interactive-bg-hover-accent); - border-radius: 8px; - background: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover-accent) 12%, transparent); - display: flex; - align-items: center; - justify-content: center; -} - -.floatDropHintLabel { - padding: 4px 12px; - border-radius: 999px; - font: var(--dsw-font-xxs-12); - color: var(--dsw-alias-label-primary); - background: var(--dsw-alias-bg-layer-1); - border: 1px solid var(--dsw-alias-border-l2); -} - /* ── Desktop drag-region opt-out ──────────────────────────────────────── */ /* Frameless desktop shells make the top strip of the web content draggable @@ -475,10 +228,8 @@ body[data-dsh-sidebar-dragging] .bottomPanel { `no-drag` itself — some shells (Tauri, older desktop builds, third-party wrappers) have no blanket button rule. The property is a no-op in plain browsers and in shells without drag regions — safe everywhere. */ -.toggleCluster, .toggleButton, -.tabBar, -.floatHeader { +.tabBar { -webkit-app-region: no-drag; } @@ -487,67 +238,10 @@ body[data-dsh-sidebar-dragging] .bottomPanel { /* Windows frameless / hidden-title-bar windows draw the native caption strip (minimize / maximize / close) at the window's top-right corner, OVER the web content. When the user enables the "position compatibility" - pref, the sidebar yields the strip: the toggle cluster drops below it and - the right panel's content starts below it — the panel's surface still - spans the full height, so the caption buttons float over empty sidebar - fill (the VSCode hidden-title-bar look). The strip height is user-tunable - (`--dsh-title-bar-strip`, written by the Sidebar shell from - `titleBarStripPx`; 40px fallback). The bottom panel is unaffected: it - sits at the window's bottom, never under the top strip. */ -:global(body[data-dsh-title-bar-compat]) .toggleCluster { - top: calc(var(--dsh-title-bar-strip, 40px) + 3px); -} - -:global(body[data-dsh-title-bar-compat]) .panel { - /* The caption strip is drawn over the panel's own top edge: keep the - surface full-height and push the content (the tab strip) below the - strip instead. .panelResize is absolutely positioned and unaffected. */ - padding-top: var(--dsh-title-bar-strip, 40px); -} - -/* ── Collapsed cluster alignment with the DSH web session header ───────── */ - -/* While the right panel is open, the cluster rides the panel's 34px tab - strip (28px buttons at top: 3). Collapsed, it floats over DSH's own - session header instead — whose controls (title, "Session log" capsule) - are vertically centered at 28px on both desktop and narrow viewports. - top: 3 then strands the cluster ~11px above that row; re-center the - 28px buttons at 14px. The attribute disappears while the panel is open, - so the tab-strip alignment above is untouched. With position - compatibility active the DSH header sits below the caption strip, hence - strip + 14. */ -:global(body[data-dsh-sidebar-collapsed]) .toggleCluster { - top: calc(14px + env(safe-area-inset-top)); -} - -:global(body[data-dsh-sidebar-collapsed][data-dsh-title-bar-compat]) .toggleCluster { - top: calc(var(--dsh-title-bar-strip, 40px) + 14px); -} - -/* The shared corner (both panels open): the intersection of the right - panel's left edge and the bottom panel's top edge. Horizontal drags - resize the width, vertical drags the height — the two panels drag - against each other. Rendered INSIDE the right panel and positioned - relative to it (left edge + the bottom panel's height from the layout - variable), so no JS-written viewport coordinates are involved. The - z-index is panel-local (above the panel's content; the panel itself sits - at 40, below the app's overlay stack). */ -.cornerHandle { - position: absolute; - left: -6px; - bottom: calc(var(--dsh-sidebar-height, 0px) + 6px); - width: 12px; - height: 12px; - z-index: 2; - cursor: nwse-resize; - touch-action: none; -} - -.cornerHandle:hover, -.cornerHandle[data-dragging] { - background: var(--dsw-alias-interactive-bg-hover-accent); -} - + pref, the Sidebar shell publishes the strip height as + `--dsh-title-bar-strip` plus `body[data-dsh-title-bar-compat]` for the + user's own CSS; the plugin's remaining chrome (the bottom workbench) sits + at the window's bottom and is never under the top strip. */ /* Icon button: the app's own icon-button pattern (ui-workspace) — a 28px circle, secondary label, hover fill; no border so the icon alone is the affordance. */ @@ -2332,6 +2026,24 @@ body[data-dsh-sidebar-dragging] .bottomPanel { color: var(--dsw-alias-state-error-primary); } +/* ── Native right-Sidebar tab-body host ────────────────────────────────── + DSH's native tab body host (`.paneBody` in dsh-client-ui-dockkit) is a + BLOCK scroller with a definite height, not a flex container — the host's + own tab bodies (TextPreview, FilesBody) declare `height: 100%` on their + root for exactly this reason. Our tab roots declare `flex: 1; min-height: + 0`, which collapses to content height in that block host (the side-chat + composer would float mid-pane). A column flex host of `height: 100%` + restores the `flex: 1` semantics our tab roots rely on, and gives + third-party descriptors' components the same box inside the native pane. + The wrapper div carries `data-dsh-native-tab-host` so the e2e lane can + assert the fill (mirroring `data-dsh-better-sidebar`). */ +.nativeTabHost { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + /* ── Diff tab (generic states + shell; the changes tab owns the rest in changes.module.css, and the diff rows themselves live in diff/diff.module.css) ────────────────────────────────────────────────── */ @@ -2466,11 +2178,8 @@ body[data-dsh-sidebar-dragging] .bottomPanel { /* Reduced motion: no slides, fades or color transitions. */ @media (prefers-reduced-motion: reduce) { - .panel, - .panelHidden, .bottomPanel, .bottomPanelHidden, - .toggleCluster, .toggleButton, .tab, .tabBarPlus, @@ -2487,20 +2196,10 @@ body[data-dsh-sidebar-dragging] .bottomPanel { /* ── Narrow (mobile) viewport ──────────────────────────────────────────── */ /* Pairs with NARROW_MAX_WIDTH = 768 in src/client/breakpoints.ts: widths - strictly below 768px (i.e. max-width: 767px) switch the sidebar to the - mobile layout — one full-screen drawer (the bottom panel's tabs were - migrated into the right tree), one toggle button. The structural - switches (which panels render, the migration) are JS-driven via - useNarrowViewport; this block only adapts the pure style details. */ + strictly below 768px (i.e. max-width: 767px) get the phone-width tab + rhythm; the bottom workbench keeps its own geometry (it spans the center + column at every width). */ @media (max-width: 767px) { - /* The toggle cluster holds a single button on narrow viewports (the - bottom panel button is not rendered), so the open panel's tab strip - reserves 40px instead of the desktop 72px — the tabs and + menu still - yield space to the one remaining control. */ - .panel:not(.panelHidden) .tabBar { - padding-right: 40px; - } - /* Narrower tabs: a phone-width drawer fits more open tabs before the strip scrolls. */ .tab { diff --git a/src/client/sidebar/bottom-toggle.tsx b/src/client/sidebar/bottom-toggle.tsx new file mode 100644 index 0000000..8af9a0e --- /dev/null +++ b/src/client/sidebar/bottom-toggle.tsx @@ -0,0 +1,29 @@ +/** + * The bottom workbench's expand/collapse control, registered into DSH's + * session header utilities list (`conversation.session.header.utilities`). + * + * DSH 0.1.5 draws its own right-Sidebar expand button in the header's corner + * slot (`conversation.session.header.corner`, a SINGLE slot owned by the + * native sidebar package), so the plugin cannot take that seat. The utilities + * list sits immediately left of it — the same row as the host's "Session + * log" capsule — which is where a dock toggle belongs: in flow, so it never + * overlaps host chrome and needs no absolute positioning or measurement. + * + * Registration goes through `slots.inject` because the declaration may not be + * on the ledger yet at plugin activation (the ui-conversation parent entry + * declares it); the callback re-runs if the declaration collapses and is + * re-declared. `order: 10` places the button after the host's own utilities. + */ +import type { Context } from '../../context-types.ts' +import type { SidebarStore } from '../state.ts' +import { BottomDockToggle } from '../Sidebar.tsx' + +/** Register the header toggle; returns the disposer. */ +export function registerBottomToggle(ctx: Context, store: SidebarStore): () => void { + return ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({ + name: 'conversation.session.header.utilities', + id: 'dsh-better-sidebar:bottom-toggle', + order: 10, + registrant: 'dsh-better-sidebar', + }, () => )) +} diff --git a/src/client/sidebar/free-windows.tsx b/src/client/sidebar/free-windows.tsx deleted file mode 100644 index 56e13b8..0000000 --- a/src/client/sidebar/free-windows.tsx +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Free windows (extracted from Sidebar.tsx, behavior identical): the - * drag-out gesture that floats a tab onto the conversation column, plus the - * render layer for the floating windows and its drop-zone hint overlay. - */ -import { useEffect, useRef, useState, type ReactNode } from 'react' -import type { Context } from '../../context-types.ts' -import { - dockFloat, floatTab, moveFloat, raiseFloat, resizeFloat, - type FloatWindow, type SidebarStore, type SidebarTab, -} from '../state.ts' -import { TAB_DRAG_TYPE, parseDrag } from '../TabBar.tsx' -import { FreeWindow } from '../FreeWindow.tsx' -import { t } from '../locales.ts' -import css from '../sidebar.module.css' - -/** The dashed drop-zone hint's geometry (viewport coordinates). */ -export interface FloatDropHint { - left: number - top: number - width: number - height: number -} - -/** - * Free windows — drag-out detection. The tab strips already drive HTML5 - * DnD (payload application/x-dsh-tab) with drops owned by the panes - * (split/merge); this hook watches the DOCUMENT (capture) for the same - * drag hovering OUTSIDE the panel host: while the pointer is over the - * conversation column it arms the drop (preventDefault) and shows a hint - * overlay there, and the drop floats the tab at the release point. Targets - * inside the host are ignored here, so pane drops keep their behavior - * untouched. Only OUR tab drags count (the body flag is the tab strip's; - * OS file drags and any DSH drags pass through). Narrow viewports skip - * the gesture — the merged drawer covers the conversation, leaving - * nothing to drop onto (the tab context menu entry still floats tabs). - */ -export function useFloatDragout(input: { - narrow: boolean - sessionId: string | undefined - store: SidebarStore - /** The AppFrame center column (from useCenterColumn); stable ref object. */ - centerColRef: { readonly current: HTMLElement | null } -}): { floatHint: FloatDropHint | null } { - const { narrow, sessionId, store, centerColRef } = input - const [floatHint, setFloatHint] = useState(null) - const floatHintRef = useRef(false) - useEffect(() => { - if (narrow || sessionId === undefined) return - const inPanelHost = (target: EventTarget | null): boolean => - target instanceof Element && target.closest('[data-dsh-panel-host]') !== null - /** The conversation column's rect when the pointer is over it (and not - * over our own surfaces); null otherwise. */ - const overConversation = (event: DragEvent): DOMRect | null => { - if (inPanelHost(event.target)) return null - const col = centerColRef.current - if (col === null || !col.isConnected) return null - const rect = col.getBoundingClientRect() - if (rect.width === 0 || rect.height === 0) return null - const { clientX: x, clientY: y } = event - if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return null - return rect - } - const onDragOver = (event: DragEvent): void => { - if (!document.body.hasAttribute('data-dsh-tab-dragging')) return - const rect = overConversation(event) - if (rect !== null) { - // preventDefault on dragover is what makes the browser deliver the - // drop (and drop the "no" cursor) over the conversation area. - event.preventDefault() - setFloatHint((prev) => { - const next = { left: rect.left, top: rect.top, width: rect.width, height: rect.height } - if (prev !== null && prev.left === next.left && prev.top === next.top - && prev.width === next.width && prev.height === next.height) return prev - return next - }) - floatHintRef.current = true - } else if (floatHintRef.current) { - floatHintRef.current = false - setFloatHint(null) - } - } - const onDrop = (event: DragEvent): void => { - if (!floatHintRef.current) return - floatHintRef.current = false - setFloatHint(null) - const rect = overConversation(event) - if (rect === null) return - event.preventDefault() - event.stopPropagation() - const payload = parseDrag(event.dataTransfer?.getData(TAB_DRAG_TYPE) ?? '') - if (payload === null) return - store.reduce(s => floatTab(s, payload.tabId, event.clientX, event.clientY)) - } - const clear = (): void => { - if (!floatHintRef.current) return - floatHintRef.current = false - setFloatHint(null) - } - document.addEventListener('dragover', onDragOver, true) - document.addEventListener('drop', onDrop, true) - window.addEventListener('dragend', clear, true) - window.addEventListener('blur', clear) - return () => { - document.removeEventListener('dragover', onDragOver, true) - document.removeEventListener('drop', onDrop, true) - window.removeEventListener('dragend', clear, true) - window.removeEventListener('blur', clear) - } - // centerColRef is a stable ref object from useCenterColumn (it lost its - // useRef provenance crossing the hook boundary, so it is listed here). - }, [narrow, sessionId, store, centerColRef]) - - return { floatHint } -} - -/** - * The free-window render layer: tabs dragged out onto the conversation area - * (or floated from the tab context menu). They live in the panel host like - * the panels (viewport coordinates, immune to desktop-shell transforms) but - * are independent of panel state — a window stays up while panels collapse. - * The floats array's order is the stacking order; the content reuses the - * regular tab renderer, so every tab type floats unchanged. Renders a - * fragment so the DOM is exactly the floats followed by the drag-out hint. - */ -export function FreeWindowLayer(props: { - floats: readonly FloatWindow[] - hint: FloatDropHint | null - renderTab: (tab: SidebarTab, active: boolean, paneId: string, placement: 'top' | 'bottom' | 'float') => ReactNode - getTabIcon: (tab: SidebarTab) => ReactNode - store: SidebarStore - ctx: Context - sessionId: string - cwd: string | undefined -}) { - const { floats, hint, renderTab, getTabIcon, store, ctx, sessionId, cwd } = props - return ( - <> - {floats.map(float => ( - renderTab(tab, active, paneId, 'float')} - getTabIcon={getTabIcon} - onRaise={() => { store.reduce(s => raiseFloat(s, float.id)) }} - onMove={(x, y) => { store.reduce(s => moveFloat(s, float.id, x, y)) }} - onResize={(w, h) => { store.reduce(s => resizeFloat(s, float.id, w, h)) }} - onDock={(paneId) => { store.reduce(s => dockFloat(s, float.id, paneId ?? undefined)) }} - onClose={() => { ctx.get('betterSidebar')?.closeTab(float.tab.id, sessionId === undefined ? undefined : { sessionId, cwd }) }} - /> - ))} - {/* - The drag-out hint: while a tab drag hovers the conversation column, - a dashed overlay marks the drop zone there (pointer-transparent — it - must not disturb the drag it describes). - */} - {hint !== null && ( -
- {t('floatDropHint')} -
- )} - - ) -} diff --git a/src/client/sidebar/use-center-column.ts b/src/client/sidebar/use-center-column.ts index 2b5d076..62d7c78 100644 --- a/src/client/sidebar/use-center-column.ts +++ b/src/client/sidebar/use-center-column.ts @@ -1,13 +1,15 @@ /** * Center-column tracking (extracted from Sidebar.tsx, behavior identical): - * the bottom panel spans ONLY the app shell's center column ("squeezes the - * agent output area") — it starts at the app sidebar's right edge and ends - * at the details column's left edge (the details column sits between the - * center and the right panel). Measured directly from the AppFrame's center - * column DOM (the parent of the [data-slot="conversation"] wrapper — - * layout.css's center column) so the bottom panel tracks the column's real - * horizontal edges — including the animated AppFrame padding reservation - * while the right panel opens/closes; a frame that never appears keeps the + * the bottom workbench spans ONLY the app shell's center column ("squeezes + * the agent output area") — it starts at the app sidebar's right edge and + * ends at the details column's left edge. Measured directly from the + * AppFrame's center column DOM (center-column.ts anchors on the conversation + * slot host — `main.conversation` on DSH 0.1.5-alpha.2+, `conversation` on + * alpha.1 — and walks up the transparent `display: contents` slot hosts to + * the column; layout.css's center column) so the workbench tracks the + * column's + * real horizontal edges, including the animated track moves while DSH's + * native right Sidebar opens/closes; a frame that never appears keeps the * initial zero-size fallback (the panel renders at 0 width until measured). * The rect lives in a REF (not state): the open/close transition resizes * the center column EVERY frame for its duration, and reacting per frame @@ -68,15 +70,17 @@ export function useCenterColumn( let disposed = false let observer: ResizeObserver | undefined // Locate the AppFrame's center column. DSH 0.1.x wraps slot hosts in - // [data-slot] containers: the conversation slot wrapper - // ([data-slot="conversation"]) sits directly inside the center column, - // so its parent IS that column — no hashed-class or positional - // dependency (layout.css uses the same anchor). The shell swaps the - // boot page for the AppFrame only AFTER boot settles, so the first - // query may miss it. Never give up: watch #root's subtree (the swap and - // HMR re-renders mutate it) and re-run this locator — querying once and - // bailing would strand the panel at the zero-size fallback forever - // (observed: a 1px sliver at the viewport's left edge). + // [data-slot] containers: the conversation slot host (key + // `main.conversation` on 0.1.5-alpha.2+, `conversation` on alpha.1) + // sits inside the center column behind transparent `display: contents` + // slot hosts, so the resolver walks up to the first ancestor that + // renders a box — no hashed-class or positional dependency + // (layout.css uses the same anchor). The shell swaps the boot page for + // the AppFrame only AFTER boot settles, so the first query may miss it. + // Never give up: watch #root's subtree (the swap and HMR re-renders + // mutate it) and re-run this locator — querying once and bailing would + // strand the panel at the zero-size fallback forever (observed: a 1px + // sliver at the viewport's left edge). const locate = (): void => { if (disposed) return // Hot path (#403): streaming output mutates #root at token cadence. diff --git a/src/client/sidebar/use-host-feeds.ts b/src/client/sidebar/use-host-feeds.ts index 4207e38..cab5c67 100644 --- a/src/client/sidebar/use-host-feeds.ts +++ b/src/client/sidebar/use-host-feeds.ts @@ -7,8 +7,7 @@ */ import { useEffect, useRef } from 'react' import type { Context, SidebarSessionList } from '../../context-types.ts' -import { firstLeaf, reconcileAgentTerminals, togglePanel, type SidebarStore } from '../state.ts' -import { isNarrowWidth } from '../breakpoints.ts' +import { reconcileAgentTerminals, type SidebarStore } from '../state.ts' import { detectNewDirectSubagent } from '../subagent-detect.ts' import { detectNewJob } from '../subagent-jobs.ts' import { t } from '../locales.ts' @@ -197,17 +196,7 @@ export function useHostFeeds(feeds: { if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return if (!store.getPrefs().autoOpenSubagent) return if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return - // Read the viewport when the delayed activation fires: a resize while - // the debounce is armed must not let background activity force the - // narrow full-screen drawer open over the chat. - if (!isNarrowWidth(window.innerWidth)) { - store.reduce(s => s.panelOpen ? s : togglePanel(s)) - } - // Choose the right panel as the landing pane for a newly created Tasks - // tab. Single-instance dedupe still activates an existing pane tab in - // place or raises an existing free window. - store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id })) - ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') }) + ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent'), target: 'bottom' }) }, AUTO_OPEN_DEBOUNCE_MS) autoOpenPendingRef.current = { baseline, timer } }, [sessionList, sessionId, store, ctx]) @@ -237,11 +226,7 @@ export function useHostFeeds(feeds: { if (!detectNewJob(prev, sessionList, sessionId)) return if (!store.getPrefs().autoOpenJobs) return if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return - if (!isNarrowWidth(window.innerWidth)) { - store.reduce(s => s.panelOpen ? s : togglePanel(s)) - } - store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id })) - ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') }) + ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent'), target: 'bottom' }) }, [sessionList, sessionId, store, ctx]) /** @@ -260,9 +245,7 @@ export function useHostFeeds(feeds: { const pending = subagentJumpRef.current if (pending === undefined || sessionId !== pending) return subagentJumpRef.current = undefined - store.reduce(s => s.panelOpen ? s : togglePanel(s)) - store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id })) - ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') }) + ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent'), target: 'bottom' }) }, [sessionId, store, ctx]) return { subagentJumpRef } diff --git a/src/client/sidebar/use-pinned-tabs.ts b/src/client/sidebar/use-pinned-tabs.ts index ce75248..689b503 100644 --- a/src/client/sidebar/use-pinned-tabs.ts +++ b/src/client/sidebar/use-pinned-tabs.ts @@ -1,13 +1,13 @@ /** * Inline pinned terminals (extracted from Sidebar.tsx, behavior identical): * pinned tabs from OTHER sessions inject as VIRTUAL tabs into the first - * leaf of the right panel's split tree, and the workbench actions are + * leaf of the bottom workbench's split tree, and the workbench actions are * wrapped so virtual ids route back to the HOME session. The shell only * consumes the augmented tree and the wrapped actions. */ import { useMemo, useState } from 'react' import { - agentUuidOf, closeFloatByTab, closeTab, isAgentTabId, leafWithTab, setTabPin, + agentUuidOf, closeTab, isAgentTabId, leafWithTab, setTabPin, type SidebarSnapshot, type SidebarStore, type SplitNode, } from '../state.ts' import { @@ -29,8 +29,8 @@ export function usePinnedTabs(input: { /** * Inline pinned terminals (v0.17.0+): pinned tabs from OTHER sessions - * inject as VIRTUAL tabs into the first leaf of the right panel's split - * tree. The virtual tabs have unique ids (prefixed with the home session) + * inject as VIRTUAL tabs into the first leaf of the bottom workbench's + * split tree. The virtual tabs have unique ids (prefixed with the home session) * and carry the home scope in meta. Clicking a virtual tab sets * `activePinnedTabId` — the augmented tree overrides the leaf's `active` * so the pinned tab's content renders in-place (TerminalView connects to @@ -65,11 +65,11 @@ export function usePinnedTabs(input: { [pinnedEntries], ) - /** The right panel's split tree with pinned virtual tabs injected into the - * first leaf. When `activePinnedTabId` is set, that leaf's `active` is - * overridden so the pinned tab's content is visible. */ + /** The bottom workbench's split tree with pinned virtual tabs injected + * into the first leaf. When `activePinnedTabId` is set, that leaf's + * `active` is overridden so the pinned tab's content is visible. */ const augmentedTree = useMemo( - () => state === undefined ? undefined : injectPinnedIntoTree(state.splits, pinnedVirtualTabs, activePinnedTabId), + () => state === undefined ? undefined : injectPinnedIntoTree(state.bottomSplits, pinnedVirtualTabs, activePinnedTabId), [state, pinnedVirtualTabs, activePinnedTabId], ) @@ -89,10 +89,8 @@ export function usePinnedTabs(input: { const vtab = pinnedVirtualTabs.find(t => t.id === virtualId) const homeCwd = vtab !== undefined ? getPinnedHomeScope(vtab)?.cwd : undefined store.reduceFor(homeSessionId, s => { - const leaf = leafWithTab(s.splits, originalId) ?? leafWithTab(s.bottomSplits, originalId) - if (leaf !== undefined) return closeTab(s, leaf.id, originalId) - if (s.floats.some(f => f.tab.id === originalId)) return closeFloatByTab(s, originalId) - return s + const leaf = leafWithTab(s.bottomSplits, originalId) + return leaf === undefined ? s : closeTab(s, leaf.id, originalId) }) if (isAgentTabId(originalId)) { void api.agentPtyClose(agentUuidOf(originalId)).catch(() => { /* already released */ }) @@ -131,10 +129,6 @@ export function usePinnedTabs(input: { if (isPinnedVirtualId(payload.tabId)) return actions.moveTabToEdge(payload, toPane, zone) }, - floatTab: (tabId) => { - if (isPinnedVirtualId(tabId)) return - actions.floatTab(tabId) - }, pinTab: (tabId, scope) => { if (isPinnedVirtualId(tabId)) { if (scope !== null) return diff --git a/src/client/sidechat-transcript.ts b/src/client/sidechat-transcript.ts index 0c9e400..56f5ae7 100644 --- a/src/client/sidechat-transcript.ts +++ b/src/client/sidechat-transcript.ts @@ -13,9 +13,10 @@ * shows only the thread's own conversation. * * Live streaming: `assistant/message` events only land when a step - * completes, but `assistant/chunk` events stream token-level text and - * reasoning deltas. The mapping accumulates both per block and supersedes - * them with the assembled message once it lands (settled rows). + * completes, but the host's `assistant/live-chunk` rows stream token-level + * text and reasoning deltas (DSH 0.1.5 publishes them outside the session + * log — see assistant-live.ts). The mapping accumulates both per block and + * supersedes them with the assembled message once it lands (settled rows). */ import type { DiffHunk, ReadBlockLine } from '@deepseek-ai/dsh-client-ui-primitives' import type { SidebarHistoryEntry } from '../context-types.ts' @@ -299,12 +300,13 @@ function resultCard( /** * Map a thread child's history rows onto compact transcript rows: the * inherited fork seed is cut at the last `session/end-seed`, context - * injections map onto a collapsible injection row, `assistant/chunk` + * injections map onto a collapsible injection row, `assistant/live-chunk` * deltas accumulate into streaming rows per (turn, step, block) and are * superseded by the assembled `assistant/message`, and tool invocations * render one expandable line each (arguments, paired result text, failure * marker; a still-executing call is marked until its result lands). - * @param entries - history rows (event + host-computed view) in seq order. + * @param entries - history rows (event + host-computed view) in seq order, + * the live deltas of the in-flight attempt appended after the durable tail. * @returns display rows in log order. */ export function transcriptRows(entries: readonly SidebarHistoryEntry[], prev?: readonly SidechatTranscriptRow[]): SidechatTranscriptRow[] { @@ -374,7 +376,7 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[], prev?: r rows.push({ kind: 'user', seq: event.seq, text }) break } - case 'assistant/chunk': { + case 'assistant/live-chunk': { const chunk = data.chunk as { type?: unknown; text?: unknown } | undefined if (chunk === null || typeof chunk !== 'object') break const kind = chunk.type === 'text-delta' ? 'assistant' : chunk.type === 'reasoning-delta' ? 'reasoning' : null diff --git a/src/client/split-pane.tsx b/src/client/split-pane.tsx index 2ed592a..7894c11 100644 --- a/src/client/split-pane.tsx +++ b/src/client/split-pane.tsx @@ -29,8 +29,6 @@ export interface WorkbenchActions { /** Reorder within a pane (drop onto another tab inserts before it). */ moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => void resizeSplit: (splitId: string, index: number, deltaFrac: number) => void - /** Float a docked tab out as a free window (tab context menu entry). */ - floatTab: (tabId: string) => void /** * Pin/unpin a terminal tab (v0.17.0+). The shell snapshots the home cwd * at pin time; null clears the pin. Optional: when undefined the tab @@ -216,7 +214,6 @@ function LeafView(props: { if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center') else actions.moveTabBefore(payload, leaf.id, before) }} - onFloatTab={actions.floatTab} onPinTab={actions.pinTab} /> {leaf.tabs.length > 0 ? ( @@ -319,7 +316,7 @@ export function Workbench(props: { return (
` so - * a reload restores the exact layout of the session it belongs to — switching + * Per-session sidebar state: the bottom workbench's split-pane tree, open + * tabs, and the explorer expansion set. One state instance per conversation + * id, persisted to localStorage under `dsh-sidebar:v1:` so a reload + * restores the exact layout of the session it belongs to — switching * conversations swaps the whole state (memory + isolation). * * The split tree is a recursive structure: a leaf holds a tab group, a split @@ -10,7 +10,6 @@ * operations are pure functions over the node, unit-tested in tests/state.spec.ts. */ import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts' -import { isNarrowWidth } from './breakpoints.ts' /** * Tab type identifier. Builtins register their ids (editor / git / terminal @@ -64,30 +63,9 @@ export interface SidebarSplit { export type SplitNode = SidebarLeaf | SidebarSplit -/** - * One free window: a tab dragged out of the workbench onto the conversation - * area floats in the panel host at viewport coordinates. The tab is OWNED by - * the window exactly like a pane owns its tabs (moved, not copied); geometry - * persists with the session so a reload restores the window in place. - * Stacking order is the array order (last = topmost). - */ -export interface FloatWindow { - id: string - tab: SidebarTab - /** Viewport coordinates of the window's top-left corner. */ - x: number - y: number - w: number - h: number -} - /** The full per-session state. */ export interface SidebarState { - panelOpen: boolean - width: number - /** The pane receiving newly opened tabs (last pane the user touched). - * Pane ids are globally unique across BOTH trees (shared uid counter), so - * one field resolves into either tree — see {@link treeOf}. */ + /** The pane receiving newly opened tabs (the last pane the user touched). */ activePane: string | null /** Monotonic terminal tab counter (ids survive reloads). */ nextTerminal: number @@ -101,9 +79,7 @@ export interface SidebarState { * unhighlighted. */ revealed: string[] - /** The right sidebar's split tree (the original workbench). */ - splits: SplitNode - /** Whether the bottom panel (a second, independent workbench) is open. */ + /** Whether the bottom panel (the plugin's one workbench) is open. */ bottomOpen: boolean /** The bottom panel's height (clamped to the contract range). */ bottomHeight: number @@ -113,11 +89,8 @@ export interface SidebarState { * on the bottomPanelAutoTerminal pref); later expansions never do. */ bottomOpenedOnce: boolean - /** The bottom panel's own split tree (panes/tabs live only in ONE tree; - * tabs never cross panels — the two panels only share panel-size drags). */ + /** The bottom workbench's split tree. */ bottomSplits: SplitNode - /** Free windows (tabs dragged out onto the conversation area). */ - floats: FloatWindow[] /** * Live agent-terminal wait state (uuid → the wait the model currently * blocks on in `terminal_wait_for`), mirrored from the host's @@ -128,24 +101,14 @@ export interface SidebarState { agentWaits: Record } -export const PANEL_MIN = 280 -export const PANEL_MAX = 640 -export const PANEL_DEFAULT = 400 export const TAB_MAX_WIDTH = 160 -/** Bottom panel geometry contract (mirrors the width contract; the upper - * bound is the viewport, enforced by {@link setBottomHeight}). */ +/** Bottom panel geometry contract (the upper bound is the viewport, enforced + * by {@link setBottomHeight}). */ export const BOTTOM_MIN = 120 export const BOTTOM_DEFAULT = 220 -/** Free-window geometry contract: the floor keeps the window usable (a - * header plus some content), the ceiling is the viewport. */ -export const FLOAT_MIN_W = 320 -export const FLOAT_MIN_H = 200 -/** Geometry a fresh free window starts with: a phone-like portrait ratio - * (390×780 ≈ 1:2). The creation path additionally caps the size to the - * viewport (minus a 24px margin), so a short viewport gets a shorter — - * not overflowing — window instead of an exact ratio. */ -export const FLOAT_DEFAULT_W = 390 -export const FLOAT_DEFAULT_H = 780 +/** The conversation column keeps at least this much height when the bottom + * workbench claims space (see {@link setBottomHeight}). */ +export const CONVERSATION_MIN = 280 let nextIdCounter = 0 /** Unique pane/tab id within one state instance. */ @@ -163,7 +126,7 @@ export function mintTabId(): string { /** * The largest numeric suffix across a raw persisted state's counter ids - * (`pane:N` / `tab:N` / `split:N` / `float:N`). The uid counter is module-global and + * (`pane:N` / `tab:N` / `split:N`). The uid counter is module-global and * resets on every reload, so a split minted AFTER a reload would collide * with the persisted ids (a fresh "pane:1" beside the persisted "pane:1"); * mapLeaf would then visit BOTH leaves and every open would land in both @@ -174,7 +137,7 @@ function maxCounterId(parsed: unknown): number { let max = 0 const consider = (id: unknown): void => { if (typeof id !== 'string') return - const match = /^(?:pane|tab|split|float):(\d+)$/.exec(id) + const match = /^(?:pane|tab|split):(\d+)$/.exec(id) if (match !== null) max = Math.max(max, Number(match[1])) } const walk = (node: unknown): void => { @@ -190,76 +153,32 @@ function maxCounterId(parsed: unknown): number { for (const child of record.children) walk(child) } } - walk((parsed as Record | null)?.splits) walk((parsed as Record | null)?.bottomSplits) - // Free windows mint through the same shared counter; their ids must seed it - // too (a persisted float:N with a lower pane max would collide otherwise). - const floats = (parsed as Record | null)?.floats - if (Array.isArray(floats)) { - for (const float of floats) { - if (float !== null && typeof float === 'object') consider((float as Record).id) - } - } return max } -/** The default tab a fresh session seeds. */ -export type DefaultSeed = 'editor-home' | 'none' - -/** A fresh default state: one seeded tab in one pane, open per the caller's - * preference. `width` is the caller's preferred panel width (default - * PANEL_DEFAULT) and `panelOpen` whether the panel starts expanded (default - * true); the store seeds new sessions from the user's side card prefs. - * `seed` picks the seeded tab: 'editor-home' places the EMPTY files window - * (an editor tab with no path whose tree panel starts open, - * `meta.treeOpen: true`) — in BOTH editorExplorer modes that window is the - * file explorer page — and 'none' starts with an empty pane (the store - * passes it when the user disabled the editor tab type in settings). */ -export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seed: DefaultSeed = 'editor-home'): SidebarState { - const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null } - if (seed === 'editor-home') { - // No path: the editor host renders its empty-state hint and the docked - // tree panel (treeOpen defaults open for path-less tabs; meta pins it). - leaf.tabs = [{ id: uid('tab'), type: 'editor', title: 'Files', meta: { treeOpen: true } }] - leaf.active = leaf.tabs[0]!.id - } - // The bottom panel starts closed with an empty pane (its welcome cards - // offer the openable types on first use). +/** + * A fresh default state: one empty pane in the bottom workbench, closed. + * (The right column belongs to DSH's native Sidebar, so this plugin's own + * layout has nothing to seed — its welcome cards offer the openable types on + * first expansion.) + */ +export function makeDefaultState(): SidebarState { const bottomLeaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null } return { - panelOpen, - width, - activePane: leaf.id, + activePane: bottomLeaf.id, nextTerminal: 1, nextBrowser: 1, expanded: [], revealed: [], - splits: leaf, bottomOpen: false, bottomHeight: BOTTOM_DEFAULT, bottomOpenedOnce: false, bottomSplits: bottomLeaf, - floats: [], agentWaits: {}, } } -/** Whether a tree node (or any descendant) carries the given pane/split id. */ -function treeHasId(node: SplitNode, id: string): boolean { - if (node.id === id) return true - if (node.kind === 'split') return node.children.some(child => treeHasId(child, id)) - return false -} - -/** Which tree owns a pane/split id: 'bottomSplits' when the id lives in the - * bottom panel's tree, else 'splits' (the right panel's tree). Ids are - * globally unique (the shared uid counter), so an id in neither tree falls - * back to the right tree, where tree operations no-op on a missing node — - * the pre-bottom-panel behavior. */ -export function treeOf(state: SidebarState, id: string): 'splits' | 'bottomSplits' { - return treeHasId(state.bottomSplits, id) ? 'bottomSplits' : 'splits' -} - /** Walk the tree and apply `visit` to the leaf with the given id. */ export function mapLeaf(node: SplitNode, paneId: string, visit: (leaf: SidebarLeaf) => void): SplitNode { if (node.kind === 'leaf') { @@ -284,44 +203,6 @@ export function firstLeaf(node: SplitNode): SidebarLeaf { return firstLeaf(node.children[0]!) } -/** Empty every leaf of a tree (the bottom tree after its tabs migrate out). */ -function clearAllTabs(node: SplitNode): SplitNode { - if (node.kind === 'leaf') return { ...node, tabs: [], active: null } - return { ...node, children: node.children.map(clearAllTabs) } -} - -/** - * Narrow-viewport migration: the bottom panel's tabs are thrown INTO the - * right sidebar — the "merged display" on mobile is the right panel alone, - * whose tab strips now carry the bottom tree's tabs (depth-first order, - * appended to the right tree's FIRST leaf). The bottom tree is emptied (its - * structure stays — the desktop bottom panel re-renders its welcome cards) - * and the panel closes. The active pane moves to the right tree's first - * leaf so every new tab lands in the visible panel. - * - * Idempotent: a bottom tree with no tabs and a closed panel returns the - * same reference. Runs when the viewport enters narrow (see the Sidebar - * shell); migrating is permanent for the session — the tabs now live in the - * right tree, exactly like the user "threw them in". - */ -export function migrateBottomTabs(state: SidebarState): SidebarState { - const bottomTabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs) - const activeInBottom = state.activePane !== null && treeHasId(state.bottomSplits, state.activePane) - if (bottomTabs.length === 0 && !state.bottomOpen && !activeInBottom) return state - const target = firstLeaf(state.splits) - return { - ...state, - activePane: target.id, - bottomOpen: false, - splits: bottomTabs.length > 0 - ? mapLeaf(state.splits, target.id, leaf => { - leaf.tabs = [...leaf.tabs, ...bottomTabs] - }) - : state.splits, - bottomSplits: bottomTabs.length > 0 ? clearAllTabs(state.bottomSplits) : state.bottomSplits, - } -} - /** Find the leaf containing a tab id, if any. */ export function leafWithTab(node: SplitNode, tabId: string): SidebarLeaf | undefined { if (node.kind === 'leaf') { @@ -340,22 +221,9 @@ export function allLeaves(node: SplitNode): SidebarLeaf[] { return node.children.flatMap(allLeaves) } -/** Whether a tab exists anywhere in a state (either tree, any pane, or any - * free window — a floating tab is as open as a docked one). */ +/** Whether a tab is open anywhere in the session's workbench. */ export function tabOpenIn(state: SidebarState, tabId: string): boolean { - return allLeaves(state.splits).some(leaf => leaf.tabs.some(tab => tab.id === tabId)) - || allLeaves(state.bottomSplits).some(leaf => leaf.tabs.some(tab => tab.id === tabId)) - || state.floats.some(float => float.tab.id === tabId) -} - -/** The free window holding a tab id, if any. */ -export function floatWithTab(state: SidebarState, tabId: string): FloatWindow | undefined { - return state.floats.find(float => float.tab.id === tabId) -} - -/** The free window with the given window id, if any. */ -export function floatById(state: SidebarState, floatId: string): FloatWindow | undefined { - return state.floats.find(float => float.id === floatId) + return allLeaves(state.bottomSplits).some(leaf => leaf.tabs.some(tab => tab.id === tabId)) } /** Replace a leaf with a split of it plus a fresh empty leaf. */ @@ -411,9 +279,6 @@ export type DropZone = 'left' | 'right' | 'up' | 'down' | 'center' * The VSCode drag gesture: move a tab out of its pane and either merge it * into the target pane (center) or split the target pane with the tab in a * fresh leaf (edge). The source pane collapses when it empties. - * - * The panes may live in DIFFERENT trees (dragging a tab between the two - * panels): the tab then leaves its own tree and lands in the other one. */ export function moveTabToEdge( state: SidebarState, @@ -426,38 +291,7 @@ export function moveTabToEdge( // Dropped back onto its own pane's center: reorder to the end. return moveTab(state, fromPane, tabId, toPane, -1) } - const key = treeOf(state, fromPane) - const toKey = treeOf(state, toPane) - if (key !== toKey) { - // Cross-panel drop: remove the tab from its own tree, then merge (center) - // or split (edge) a pane of the OTHER tree with the tab. - const source = leafWithTab(state[key], tabId) - if (source === undefined) return state - const tab = source.tabs.find(candidate => candidate.id === tabId)! - let emptied = false - let sourceNode = mapLeaf(state[key], source.id, (leaf) => { - leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId) - if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null - if (leaf.tabs.length === 0) emptied = true - }) - if (emptied) sourceNode = removeLeafAt(sourceNode, source.id) - let targetNode = state[toKey] - let activePane: string - if (zone === 'center') { - targetNode = mapLeaf(targetNode, toPane, (leaf) => { - leaf.tabs = [...leaf.tabs, tab] - leaf.active = tab.id - }) - activePane = toPane - } else { - const dir = zone === 'left' || zone === 'right' ? 'row' : 'col' - const result = insertLeafAt(targetNode, toPane, dir, tab, zone === 'left' || zone === 'up') - targetNode = result.node - activePane = result.leafId - } - return { ...state, [key]: sourceNode, [toKey]: targetNode, activePane } - } - const node = state[key] + const node = state.bottomSplits const source = leafWithTab(node, tabId) if (source === undefined) return state const tab = source.tabs.find(candidate => candidate.id === tabId)! @@ -473,11 +307,11 @@ export function moveTabToEdge( leaf.tabs = [...leaf.tabs, tab] leaf.active = tab.id }) - return { ...state, [key]: splits, activePane: toPane } + return { ...state, bottomSplits: splits, activePane: toPane } } const dir = zone === 'left' || zone === 'right' ? 'row' : 'col' const result = insertLeafAt(splits, toPane, dir, tab, zone === 'left' || zone === 'up') - return { ...state, [key]: result.node, activePane: result.leafId } + return { ...state, bottomSplits: result.node, activePane: result.leafId } } /** @@ -500,7 +334,7 @@ export function removeLeafAt(node: SplitNode, paneId: string): SplitNode { /** Close a tab; an emptied leaf is removed (unless it is the only pane). */ export function closeTab(state: SidebarState, paneId: string, tabId: string): SidebarState { - const key = treeOf(state, paneId) + const key = 'bottomSplits' let emptied = false const splits = mapLeaf(state[key], paneId, (leaf) => { leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId) @@ -512,7 +346,7 @@ export function closeTab(state: SidebarState, paneId: string, tabId: string): Si /** Activate a tab in its pane (the pane's own tree). */ export function activateTab(state: SidebarState, paneId: string, tabId: string): SidebarState { - const key = treeOf(state, paneId) + const key = 'bottomSplits' return { ...state, activePane: paneId, @@ -550,17 +384,14 @@ export function patchTab( const children = node.children.map(walk) return children === node.children ? node : { ...node, children } } - const splits = walk(state.splits) const bottomSplits = walk(state.bottomSplits) - const floats = state.floats.map(float => (float.tab.id === tabId ? { ...float, tab: apply(float.tab) } : float)) - return changed ? { ...state, splits, bottomSplits, floats } : state + return changed ? { ...state, bottomSplits } : state } /** * Set or clear the pin marker on one open tab (v0.17.0+). A pin marker is * structural metadata (NOT display fields like title/path), so it walks - * both split trees AND the free windows exactly like {@link patchTab} — - * the tab may live in either tree or float. Passing `null` clears the pin + * the workbench's split tree exactly like {@link patchTab}. Passing `null` clears the pin * (the tab stays open in its home session); passing a `{ scope, homeCwd }` * object sets it. An unknown tab id is a strict no-op (same reference * returned) so a stale pin request never churns the state or rewrites @@ -615,54 +446,31 @@ export function setTabPin( if (children.every((child, i) => child === node.children[i])) return node return { ...node, children } } - const splits = walk(state.splits) const bottomSplits = walk(state.bottomSplits) - const floatIdx = state.floats.findIndex(f => f.tab.id === tabId) - const floats = floatIdx < 0 ? state.floats : (() => { - const oldFloat = state.floats[floatIdx]! - const newTab = apply(oldFloat.tab) - if (newTab === oldFloat.tab) return state.floats - const next = state.floats.slice() - next[floatIdx] = { ...oldFloat, tab: newTab } - return next - })() - return changed ? { ...state, splits, bottomSplits, floats } : state + return changed ? { ...state, bottomSplits } : state } /** - * Land a tab in the active pane (or focus its existing instance by id). - * Dedup strategies (single-instance, per-path, per-change) are owned by the - * tab descriptor through {@link BetterSidebarService.openTab} / `dedupeKey`; - * this reducer only handles the id-based safety net (reconcile and - * openDiffTab already check existence before calling) and the landing - * itself — the service's dedupe path delegates here after its dedupeKey - * check misses. - * - * The active pane may live in EITHER tree (pane ids are globally unique): - * a stale id that survives in neither tree falls back to the right tree's - * first pane instead of swallowing the open. + * Land a tab in the workbench's first pane — the plugin's own opens (its + * bottom-panel + menu, the auto-terminal, and every open when no native + * surface is installed): the plugin owns no right column any more (DSH's + * native sidebar is the right one), so the bottom workbench is the only tree. + * @param state - the session state. + * @param tab - the tab to land. + * @returns the next state, with the bottom panel open. */ -export function openTabInActivePane(state: SidebarState, tab: SidebarTab): SidebarState { - let targetId = state.activePane ?? firstLeaf(state.splits).id - // A stale activePane (its pane was closed since) must not swallow the - // open: fall back to the first pane of the right tree instead of dropping - // the tab. - if (!allLeaves(state[treeOf(state, targetId)]).some(leaf => leaf.id === targetId)) { - targetId = firstLeaf(state.splits).id - } - const targetKey = treeOf(state, targetId) - // Id-based safety net: if a tab with the same id exists, focus it — in a - // pane (activate) or in a free window (raise, no panel switch). - for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) { +export function openTabInBottomPane(state: SidebarState, tab: SidebarTab): SidebarState { + const targetId = firstLeaf(state.bottomSplits).id + // Id-based safety net: if a tab with the same id exists, focus it. + for (const leaf of allLeaves(state.bottomSplits)) { const existing = leaf.tabs.find(candidate => candidate.id === tab.id) if (existing !== undefined) return activateTab(state, leaf.id, existing.id) } - const floated = floatWithTab(state, tab.id) - if (floated !== undefined) return raiseFloat(state, floated.id) return { ...state, + bottomOpen: true, activePane: targetId, - [targetKey]: mapLeaf(state[targetKey], targetId, (leaf) => { + bottomSplits: mapLeaf(state.bottomSplits, targetId, (leaf) => { leaf.tabs = [...leaf.tabs, tab] leaf.active = tab.id }), @@ -673,8 +481,8 @@ export function openTabInActivePane(state: SidebarState, tab: SidebarTab): Sideb * The panes may live in DIFFERENT trees — dragging a tab between the two * panels removes it from its own tree and lands it in the other one. */ export function moveTab(state: SidebarState, fromPane: string, tabId: string, toPane: string, index = -1): SidebarState { - const fromKey = treeOf(state, fromPane) - const toKey = treeOf(state, toPane) + const fromKey = 'bottomSplits' + const toKey = 'bottomSplits' if (fromKey !== toKey) { let moved: SidebarTab | undefined let emptied = false @@ -721,8 +529,8 @@ export function moveTab(state: SidebarState, fromPane: string, tabId: string, to /** Split the active pane (or the pane containing the active tab). */ export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState { - const paneId = state.activePane ?? firstLeaf(state.splits).id - const key = treeOf(state, paneId) + const paneId = state.activePane ?? firstLeaf(state.bottomSplits).id + const key = 'bottomSplits' return { ...state, [key]: splitLeafAt(state[key], paneId, dir) } } @@ -741,14 +549,14 @@ export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState * @returns the new state, with the diff pane active. */ export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: SidebarTab): SidebarState { - const existingLeaf = leafWithTab(state.splits, tab.id) + const existingLeaf = leafWithTab(state.bottomSplits, tab.id) if (existingLeaf !== undefined) return activateTab(state, existingLeaf.id, tab.id) - const diffLeaf = allLeaves(state.splits).find(leaf => leaf.tabs.some(candidate => candidate.type === 'diff')) + const diffLeaf = allLeaves(state.bottomSplits).find(leaf => leaf.tabs.some(candidate => candidate.type === 'diff')) if (diffLeaf !== undefined) { return { ...state, activePane: diffLeaf.id, - splits: mapLeaf(state.splits, diffLeaf.id, (leaf) => { + bottomSplits: mapLeaf(state.bottomSplits, diffLeaf.id, (leaf) => { leaf.tabs = [...leaf.tabs, tab] leaf.active = tab.id }), @@ -757,37 +565,25 @@ export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: Side // First diff: split the source pane, the diff tab in the new LOWER leaf. // (A stale sourcePaneId — its pane closed meanwhile — degrades to the // regular open path instead of dropping the tab into an orphaned leaf.) - if (!allLeaves(state.splits).some(leaf => leaf.id === sourcePaneId)) { - return openTabInActivePane(state, tab) + if (!allLeaves(state.bottomSplits).some(leaf => leaf.id === sourcePaneId)) { + return openTabInBottomPane(state, tab) } - const result = insertLeafAt(state.splits, sourcePaneId, 'col', tab, false) - return { ...state, splits: result.node, activePane: result.leafId } + const result = insertLeafAt(state.bottomSplits, sourcePaneId, 'col', tab, false) + return { ...state, bottomSplits: result.node, activePane: result.leafId } } -/** Toggle the panel open/closed (opening restores the previous layout). */ -export function togglePanel(state: SidebarState): SidebarState { - return { ...state, panelOpen: !state.panelOpen } -} - -/** Toggle the bottom panel open/closed (independent of the right panel). */ +/** Expand/collapse the bottom workbench. */ export function toggleBottomPanel(state: SidebarState): SidebarState { return { ...state, bottomOpen: !state.bottomOpen } } -/** Set the panel width (clamped to the contract range; the upper bound is - * the viewport so the fullscreen expansion can fill the window). */ -export function setWidth(state: SidebarState, width: number): SidebarState { - const max = typeof window !== 'undefined' ? Math.max(PANEL_MIN, window.innerWidth) : PANEL_MAX - return { ...state, width: Math.min(max, Math.max(PANEL_MIN, Math.round(width))) } -} - -/** Set the bottom panel height (clamped to the contract range). The upper - * bound leaves the center column (the agent output area) at least PANEL_MIN - * tall — without the cap the bottom panel could swallow the whole viewport - * and squeeze the conversation to zero height. */ +/** Set the bottom workbench height (clamped to the contract range). The + * upper bound leaves the conversation column at least {@link CONVERSATION_MIN} + * tall — without the cap the workbench could swallow the whole viewport and + * squeeze the conversation to zero height. */ export function setBottomHeight(state: SidebarState, height: number): SidebarState { const viewport = typeof window !== 'undefined' ? window.innerHeight : Infinity - const max = Math.max(BOTTOM_MIN, viewport - PANEL_MIN) + const max = Math.max(BOTTOM_MIN, viewport - CONVERSATION_MIN) return { ...state, bottomHeight: Math.min(max, Math.max(BOTTOM_MIN, Math.round(height))) } } @@ -852,147 +648,10 @@ export function resizeSplit(node: SplitNode, splitId: string, index: number, del /** State-level {@link resizeSplit} route: the divider may live in either * tree (split ids are globally unique). */ export function resizeSplitIn(state: SidebarState, splitId: string, index: number, delta: number): SidebarState { - const key = treeOf(state, splitId) + const key = 'bottomSplits' return { ...state, [key]: resizeSplit(state[key], splitId, index, delta) } } -// ── Free windows ──────────────────────────────────────────────────────────── - -/** The viewport size, or Infinity where there is no (usable) window — unit - * tests stub partial window objects, and a NaN bound would poison geometry. */ -function viewportW(): number { - return typeof window !== 'undefined' && Number.isFinite(window.innerWidth) ? window.innerWidth : Infinity -} - -function viewportH(): number { - return typeof window !== 'undefined' && Number.isFinite(window.innerHeight) ? window.innerHeight : Infinity -} - -/** Clamp free-window geometry: sizes respect the floor and the viewport, and - * the position keeps the whole window inside the viewport. Without a window - * (unit tests) only the floor applies — the caller's values pass through. */ -export function clampFloatGeometry(x: number, y: number, w: number, h: number): Pick { - const vw = viewportW() - const vh = viewportH() - const width = Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw))) - const height = Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh))) - return { - x: Math.round(Math.min(Math.max(x, 0), Math.max(0, vw - width))), - y: Math.round(Math.min(Math.max(y, 0), Math.max(0, vh - height))), - w: width, - h: height, - } -} - -/** - * Float a docked tab: remove it from its pane (either tree; an emptied pane - * collapses like any move) and append a free window centered on the drop - * point, with the default size clamped to the viewport. The stacking order - * is the array order, so a fresh window is born topmost. An unknown tab id - * (or one already floating) is a strict no-op. - */ -export function floatTab(state: SidebarState, tabId: string, x: number, y: number): SidebarState { - let source: SidebarLeaf | undefined - let key: 'splits' | 'bottomSplits' | undefined - for (const treeKey of ['splits', 'bottomSplits'] as const) { - source = leafWithTab(state[treeKey], tabId) - if (source !== undefined) { key = treeKey; break } - } - if (key === undefined || source === undefined) return state - const tab = source.tabs.find(candidate => candidate.id === tabId)! - let emptied = false - let node = mapLeaf(state[key], source.id, (leaf) => { - leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId) - if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null - if (leaf.tabs.length === 0) emptied = true - }) - if (emptied) node = removeLeafAt(node, source.id) - // Phone-ratio default, capped to the viewport before centering so the - // clamped position never leaves the window's bottom past the fold. - const vw = viewportW() - const vh = viewportH() - const width = Math.min(FLOAT_DEFAULT_W, Math.max(FLOAT_MIN_W, vw - 24)) - const height = Math.min(FLOAT_DEFAULT_H, Math.max(FLOAT_MIN_H, vh - 24)) - const window = clampFloatGeometry( - x - width / 2, - y - height / 2, - width, - height, - ) - const next: SidebarState = { - ...state, - [key]: node, - floats: [...state.floats, { id: uid('float'), tab, ...window }], - } - // The pane the user was working in may have just collapsed with the tab. - if (emptied && state.activePane === source.id) next.activePane = firstLeaf(next.splits).id - return next -} - -/** Move a free window (clamped to the viewport); unknown ids are a no-op. */ -export function moveFloat(state: SidebarState, floatId: string, x: number, y: number): SidebarState { - const float = floatById(state, floatId) - if (float === undefined) return state - const geo = clampFloatGeometry(x, y, float.w, float.h) - if (geo.x === float.x && geo.y === float.y) return state - return { ...state, floats: state.floats.map(f => (f.id === floatId ? { ...f, ...geo } : f)) } -} - -/** Resize a free window from its SE corner: the top-left corner stays - * anchored, sizes clamp to the floor and to the viewport's remaining room. */ -export function resizeFloat(state: SidebarState, floatId: string, w: number, h: number): SidebarState { - const float = floatById(state, floatId) - if (float === undefined) return state - const vw = viewportW() - const vh = viewportH() - const width = Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw - float.x))) - const height = Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh - float.y))) - if (width === float.w && height === float.h) return state - return { ...state, floats: state.floats.map(f => (f.id === floatId ? { ...f, w: width, h: height } : f)) } -} - -/** Bring a free window to the top (the array's end). Already topmost (or the - * only window) returns the same reference — no persist churn on every click. */ -export function raiseFloat(state: SidebarState, floatId: string): SidebarState { - if (state.floats.length < 2) return state - const index = state.floats.findIndex(f => f.id === floatId) - if (index < 0 || index === state.floats.length - 1) return state - const floats = [...state.floats] - const [raised] = floats.splice(index, 1) - floats.push(raised!) - return { ...state, floats } -} - -/** Dock a free window back into a pane (center merge): the tab joins the - * target pane and activates. `toPane` defaults to the active pane with the - * right tree's first leaf as the stale-id fallback (mirrors - * {@link openTabInActivePane}). Unknown window ids are a no-op. */ -export function dockFloat(state: SidebarState, floatId: string, toPane?: string): SidebarState { - const float = floatById(state, floatId) - if (float === undefined) return state - let targetId = toPane ?? state.activePane ?? firstLeaf(state.splits).id - if (!allLeaves(state[treeOf(state, targetId)]).some(leaf => leaf.id === targetId)) { - targetId = firstLeaf(state.splits).id - } - const targetKey = treeOf(state, targetId) - return { - ...state, - floats: state.floats.filter(f => f.id !== floatId), - activePane: targetId, - [targetKey]: mapLeaf(state[targetKey], targetId, (leaf) => { - leaf.tabs = [...leaf.tabs, float.tab] - leaf.active = float.tab.id - }), - } -} - -/** Close the free window holding a tab (the tab closes WITH the window — - * the caller fires the descriptor's onClose lifecycle). */ -export function closeFloatByTab(state: SidebarState, tabId: string): SidebarState { - if (!state.floats.some(f => f.tab.id === tabId)) return state - return { ...state, floats: state.floats.filter(f => f.tab.id !== tabId) } -} - /** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */ export const AGENT_TAB_PREFIX = 'agent:' @@ -1044,8 +703,7 @@ export function reconcileAgentTerminals( state: SidebarState, agentTerminals: ReadonlyArray<{ uuid: string; title: string; waiting?: { needle: string; since: number } | null }>, ): SidebarState { - const existingTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs) - .concat(state.floats.map(float => float.tab)) + const existingTabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs) const existingAgentTabs = existingTabs.filter(tab => isAgentTabId(tab.id)) const existingUuids = new Set(existingAgentTabs.map(tab => agentUuidOf(tab.id))) const serverUuids = new Set(agentTerminals.map(t => t.uuid)) @@ -1069,29 +727,25 @@ export function reconcileAgentTerminals( } if (toAdd.length === 0 && toRemove.length === 0 && sameAgentWaits(state.agentWaits, serverWaits)) return state // Remove tabs whose uuids vanished from the server list (the agent closed - // them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup; - // a FLOATED agent terminal leaves with its window. - let splits = state.splits - let floats = state.floats + // them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup. + let bottomSplits = state.bottomSplits for (const tab of toRemove) { - const leaf = leafWithTab(splits, tab.id) + const leaf = leafWithTab(bottomSplits, tab.id) if (leaf !== undefined) { - splits = closeTab({ ...state, splits }, leaf.id, tab.id).splits - } - if (floats.some(float => float.tab.id === tab.id)) { - floats = floats.filter(float => float.tab.id !== tab.id) + bottomSplits = closeTab({ ...state, bottomSplits }, leaf.id, tab.id).bottomSplits } } // Add tabs for new uuids (the agent created a terminal). They land in the - // active pane via openTabInActivePane; the next reconcile is a no-op for them. - let next: SidebarState = { ...state, splits, floats } + // workbench's landing pane via openTabInBottomPane; the next reconcile is a + // no-op for them. + let next: SidebarState = { ...state, bottomSplits } for (const terminal of toAdd) { const tab: SidebarTab = { id: agentTabId(terminal.uuid), type: 'terminal', title: terminal.title, } - next = openTabInActivePane(next, tab) + next = openTabInBottomPane(next, tab) } return { ...next, agentWaits: serverWaits } } @@ -1107,37 +761,6 @@ const STORAGE_PREFIX = 'dsh-sidebar:v1' * cache-hit session switches, so a drag in one conversation carries to all * the others (last drag wins). */ -const GLOBAL_WIDTH_KEY = 'dsh-sidebar:v1:width' - -/** Clamp one width to the contract and the current viewport (mirror of {@link setWidth}). */ -function clampWidth(width: number): number { - const max = typeof window !== 'undefined' ? Math.max(PANEL_MIN, window.innerWidth) : PANEL_MAX - return Math.min(max, Math.max(PANEL_MIN, Math.round(width))) -} - -/** Read the cross-session panel width (undefined when never dragged). */ -function readGlobalWidth(): number | undefined { - try { - const raw = localStorage.getItem(GLOBAL_WIDTH_KEY) - if (raw !== null) { - const parsed = Number(raw) - if (Number.isFinite(parsed) && parsed > 0) return clampWidth(parsed) - } - } catch { - // Storage unavailable: fall back to the per-session behavior. - } - return undefined -} - -/** Persist the cross-session panel width (best-effort, like the session states). */ -function writeGlobalWidth(width: number): void { - try { - localStorage.setItem(GLOBAL_WIDTH_KEY, String(width)) - } catch { - // Storage full or unavailable: layout memory is best-effort. - } -} - /** Immutable snapshot handed to React (replaced only on real changes). */ export interface SidebarSnapshot { sessionId: string | undefined @@ -1150,13 +773,6 @@ export interface SidebarSnapshot { prefs: SidebarPrefs } -/** Default panel width for one viewport: the prefs percent of the window, - * clamped to the panel floor (a tiny percent must stay usable) and to the - * viewport (a large one must never cover the whole window). */ -export function defaultWidthFor(viewport: number, percent: number): number { - return Math.min(viewport, Math.max(PANEL_MIN, Math.round(viewport * percent / 100))) -} - /** * URL escape hatch (#369): loading the app with `?dsh-sidebar-reset` drops * the persisted layout for the session instead of restoring it. When a @@ -1176,21 +792,15 @@ function resetRequested(): boolean { } } -function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState { +function loadState(sessionId: string): SidebarState { const reset = resetRequested() - const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined if (reset) { try { localStorage.removeItem(`${STORAGE_PREFIX}:${sessionId}`) - localStorage.removeItem(GLOBAL_WIDTH_KEY) } catch { // Storage unavailable: the default layout below is still the escape. } } - // The panel width is a cross-session preference: the last dragged width - // wins over a session's own persisted value, so switching conversations - // keeps the width the user chose anywhere. - const globalWidth = reset ? undefined : readGlobalWidth() if (!reset) { try { const raw = localStorage.getItem(`${STORAGE_PREFIX}:${sessionId}`) @@ -1200,38 +810,13 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState { // sanitize re-ids any duplicates the pre-seeding counter left behind. nextIdCounter = maxCounterId(parsed) const sanitized = sanitizeState(parsed) - if (sanitized !== undefined) { - const restored = globalWidth === undefined ? sanitized : { ...sanitized, width: globalWidth } - // A narrow panel is a full-screen drawer, so every page/session load - // starts it closed even when the prior desktop/mobile state persisted - // `panelOpen: true`. Preserve the entire restored workbench (panes, - // tabs, bottom panel, and free windows); visibility is the sole - // viewport-derived override. - return viewport !== undefined && isNarrowWidth(viewport) && restored.panelOpen - ? { ...restored, panelOpen: false } - : restored - } + if (sanitized !== undefined) return sanitized } } catch { // Corrupt or unavailable storage: fall through to the default. } } - // New sessions seed from the user's side card prefs: the width is the - // chosen percent of the window (clamped to the panel floor and the - // viewport so a huge percent can never crush the app shell), the panel - // starts open only when the preference says so, and the seed tab is the - // empty files window (tree panel open) in BOTH editorExplorer modes — a - // disabled editor type seeds nothing. On a NARROW viewport a brand-new - // session starts collapsed instead — the panel is a full-screen drawer - // there, and auto-opening it on first paint would cover the conversation - // before the user asked. Persisted layouts follow the same narrow-load - // visibility rule above, while their workbench contents remain intact. - const width = globalWidth ?? (viewport === undefined - ? PANEL_DEFAULT - : defaultWidthFor(viewport, prefs.defaultWidthPercent)) - const openByDefault = prefs.openByDefault && (viewport === undefined || !isNarrowWidth(viewport)) - const seed: DefaultSeed = prefs.tabsEnabled['editor'] === false ? 'none' : 'editor-home' - return makeDefaultState(width, openByDefault, seed) + return makeDefaultState() } /** @@ -1245,8 +830,6 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState { export function sanitizeState(parsed: unknown): SidebarState | undefined { if (parsed === null || typeof parsed !== 'object') return undefined const record = parsed as Record - if (typeof record.panelOpen !== 'boolean') return undefined - if (typeof record.width !== 'number' || !Number.isFinite(record.width)) return undefined if (typeof record.nextTerminal !== 'number' || !Number.isInteger(record.nextTerminal) || record.nextTerminal < 1) { return undefined } @@ -1258,67 +841,36 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined { : 1 if (typeof record.activePane !== 'string' && record.activePane !== null) return undefined if (!Array.isArray(record.expanded) || record.expanded.some(item => typeof item !== 'string')) return undefined - // The seen/reid maps are SHARED across both trees: pane/split ids must be - // globally unique (the runtime uid counter is shared too), so a duplicate - // seen first in the right tree gets a fresh id when it reappears in the - // bottom tree. + // Pane/split ids must be globally unique (the runtime uid counter is + // shared), so a duplicate id gets a fresh one. const seen = new Set() const reid = new Map() - const restoredSplits = sanitizeNode(record.splits, seen, reid) - if (restoredSplits === undefined) return undefined - const splits = pruneEmptyPanes(restoredSplits) - // Bottom-panel fields arrived in a later build: a missing or malformed - // value on an OLDER persisted state defaults (closed / default height / - // empty pane) so existing layouts keep loading, like nextBrowser. + // Workbench fields arrived in a later build: a missing or malformed value + // on an OLDER persisted state defaults (closed / default height / empty + // pane) so existing layouts keep loading, like nextBrowser. const bottomOpen = record.bottomOpen === true - // Cap the persisted height so the center column (the agent output area) - // keeps at least PANEL_MIN tall (a stale full-height bottom panel from an - // older build must never squeeze the conversation to zero). + // Cap the persisted height so the conversation column (the agent output + // area) keeps a usable minimum (a stale full-height panel from an older + // build must never squeeze the conversation to zero). The cap is + // setBottomHeight's own contract, or a restored height would snap on the + // first drag. const maxHeight = typeof window !== 'undefined' ? window.innerHeight : Infinity - const bottomCap = Math.max(BOTTOM_MIN, maxHeight - PANEL_MIN) + const bottomCap = Math.max(BOTTOM_MIN, maxHeight - CONVERSATION_MIN) const rawHeight = typeof record.bottomHeight === 'number' && Number.isFinite(record.bottomHeight) ? record.bottomHeight : BOTTOM_DEFAULT const bottomHeight = Math.min(bottomCap, Math.max(BOTTOM_MIN, Math.round(rawHeight))) const bottomSplits = pruneEmptyPanes(sanitizeNode(record.bottomSplits, seen, reid) ?? { kind: 'leaf' as const, id: uid('pane'), tabs: [], active: null }) - // Free windows arrived with the float feature: a missing field on an older - // persisted state defaults to none (like the bottom-panel fields above). - // Malformed ENTRIES drop individually — unlike the split trees, where - // corruption resets the whole state, a bad window must not cost the user - // their whole layout. Geometry clamps into the CURRENT viewport so a stale - // off-screen window stays reachable; the window ids join the same - // uniqueness set as the pane/split ids (the uid counter mints for both). - const floats: FloatWindow[] = [] - if (Array.isArray(record.floats)) { - for (const entry of record.floats) { - if (entry === null || typeof entry !== 'object') continue - const candidate = entry as Record - if (typeof candidate.id !== 'string' || seen.has(candidate.id)) continue - const tab = sanitizePersistedTab(candidate.tab) - if (tab === undefined || tab === 'diff') continue - if ( - typeof candidate.x !== 'number' || !Number.isFinite(candidate.x) - || typeof candidate.y !== 'number' || !Number.isFinite(candidate.y) - || typeof candidate.w !== 'number' || !Number.isFinite(candidate.w) - || typeof candidate.h !== 'number' || !Number.isFinite(candidate.h) - ) continue - seen.add(candidate.id) - floats.push({ id: candidate.id, tab, ...clampFloatGeometry(candidate.x, candidate.y, candidate.w, candidate.h) }) - } - } const requestedActivePane = typeof record.activePane === 'string' ? (reid.get(record.activePane) ?? record.activePane) : null const activePane = requestedActivePane === null ? null - : treeHasId(splits, requestedActivePane) || treeHasId(bottomSplits, requestedActivePane) + : allLeaves(bottomSplits).some(leaf => leaf.id === requestedActivePane) ? requestedActivePane - : firstLeaf(splits).id - const maxWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity + : firstLeaf(bottomSplits).id return { - panelOpen: record.panelOpen, - width: Math.max(PANEL_MIN, Math.min(record.width, maxWidth)), // A stale duplicate pane id may have been re-ided; follow the rename so // new tabs still land in the pane the user was using. activePane, @@ -1326,7 +878,6 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined { nextBrowser, expanded: record.expanded as string[], revealed: [], - splits, bottomOpen, bottomHeight, // An older persisted state never expanded the bottom panel (the field @@ -1334,7 +885,6 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined { // auto-terminal exactly once after the upgrade. bottomOpenedOnce: record.bottomOpenedOnce === true, bottomSplits, - floats, // The agent wait state is TRANSIENT (like revealed): never restored from // storage — the host's first push after attach repopulates it. agentWaits: {}, @@ -1528,20 +1078,13 @@ export class SidebarStore { } else { let state = this.bySession.get(sessionId) if (state === undefined) { - state = loadState(sessionId, this.prefs) + state = loadState(sessionId) this.bySession.set(sessionId, state) } else { // Cache hit: another session's load/ops may have left the uid // counter below THIS session's persisted ids — re-seed so fresh // pane/split ids can never collide with its tree. nextIdCounter = maxCounterId(state) - // The panel width is cross-session: adopt the latest dragged width - // (a cached session keeps its own layout otherwise). - const globalWidth = readGlobalWidth() - if (globalWidth !== undefined && state.width !== globalWidth) { - state = { ...state, width: globalWidth } - this.bySession.set(sessionId, state) - } } this.snapshot = { sessionId, state, prefs: this.prefs } } @@ -1632,7 +1175,7 @@ export class SidebarStore { const counterBefore = nextIdCounter let state = this.bySession.get(sessionId) if (state === undefined) { - state = loadState(sessionId, this.prefs) + state = loadState(sessionId) this.bySession.set(sessionId, state) } else { // Re-seed the uid counter past THIS session's persisted ids, exactly @@ -1649,14 +1192,6 @@ export class SidebarStore { } private schedulePersist(sessionId: string, state: SidebarState): void { - // Keep the cross-session width in sync: any width change (drag, fullscreen - // toggle) on the ACTIVE session becomes the shared width for every - // conversation. A targeted open persists an INACTIVE session (reduceFor) - // and must not clobber that global — its width is stale by definition, - // so writing it would break "last drag wins". - if (sessionId === this.snapshot.sessionId) { - writeGlobalWidth(state.width) - } // Per-session debounce timers: one session's pending write must never // cancel another's (targeted opens schedule writes for INACTIVE // sessions while the active session may already have one pending — diff --git a/src/client/tree-mutations.ts b/src/client/tree-mutations.ts index 8b1719c..6fa1641 100644 --- a/src/client/tree-mutations.ts +++ b/src/client/tree-mutations.ts @@ -1,8 +1,8 @@ /** * Open-tab reconciliation after a file-tree mutation (rename/delete). * - * The tree owns the rows; the TABS live in the sidebar state (either split - * tree, the bottom panel, or free windows). A rename must retarget every + * The tree owns the rows; the TABS live in the sidebar state (the bottom + * workbench's split tree). A rename must retarget every * tab whose `path` is the renamed file (the editor content survives and * later saves land on the new path); a delete must close every tab at or * under the removed path — files and anything inside a removed directory @@ -16,15 +16,12 @@ import { baseName } from './FileTree.tsx' import { allLeaves, type SidebarSnapshot, type SidebarStore, type SidebarTab } from './state.ts' import { isWithinWorkspace } from './paths.ts' -/** Every open tab that carries a file path (either split tree, the bottom - * panel, and free windows — a floating tab is as open as a docked one). */ +/** Every open tab that carries a file path. */ function pathTabsOf(snapshot: SidebarSnapshot): SidebarTab[] { const state = snapshot.state if (state === undefined) return [] const tabs: SidebarTab[] = [] - for (const leaf of allLeaves(state.splits)) tabs.push(...leaf.tabs) for (const leaf of allLeaves(state.bottomSplits)) tabs.push(...leaf.tabs) - for (const float of state.floats) tabs.push(float.tab) return tabs.filter(tab => tab.path !== undefined) } diff --git a/src/config.ts b/src/config.ts index 3d055cb..07e5d4a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,9 +13,6 @@ import { TITLE_BAR_STRIP_DEFAULT, TITLE_BAR_STRIP_MAX, TITLE_BAR_STRIP_MIN, - WIDTH_PERCENT_DEFAULT, - WIDTH_PERCENT_MAX, - WIDTH_PERCENT_MIN, type SidebarPrefs, } from './prefs-shared.ts' @@ -28,9 +25,6 @@ export { TITLE_BAR_STRIP_DEFAULT, TITLE_BAR_STRIP_MAX, TITLE_BAR_STRIP_MIN, - WIDTH_PERCENT_DEFAULT, - WIDTH_PERCENT_MAX, - WIDTH_PERCENT_MIN, type SidebarPrefs, } from './prefs-shared.ts' @@ -115,8 +109,6 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve /** Schemastery schema for the user-facing preferences (validated by the settings service). */ export const PrefsSchema: z = z.object({ - openByDefault: z.boolean().default(false), - defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT), autoOpenSubagent: z.boolean().default(true), autoOpenJobs: z.boolean().default(true), agentTerminalTools: z.boolean().default(false), @@ -124,9 +116,7 @@ export const PrefsSchema: z = z.object({ bottomPanelAutoTerminal: z.boolean().default(true), terminalFontFamily: z.string().default(''), terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT), - interceptOpenPath: z.boolean().default(true), editorExplorer: z.boolean().default(false), - changesDiffFloat: z.boolean().default(true), workspaceFence: z.boolean().default(true), terminalShell: z.string().default(''), terminalShellArgs: z.string().default(''), diff --git a/src/context-types.ts b/src/context-types.ts index 3730749..9f97b7d 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -296,14 +296,34 @@ export interface SidebarSessionTitleService { rename(session: unknown, title: string): { title: string; eventSeq: number } } -/** The host session-persistence face (mirror of the sessionPersistence - * service): detached inspection of a persisted session, used to compose the - * recorded preset when a Side Chat thread cold-resumes. */ +/** + * The host session-persistence face (mirror of the `sessionPersistence` + * service): durable, handle-addressed session storage. + * + * DSH 0.1.5 replaced the detached `inspect(id)` call with an explicit read + * handle: `open(id, 'read')` never takes write ownership and works while + * another process owns the session, `handle.read()` returns one contiguous + * slice of the log, and `close()` releases it. Every cold read in this plugin + * goes through {@link readPersistedSession} so the handle is always closed. + */ export interface SidebarSessionPersistenceService { - inspect(sessionId: string): Promise<{ - meta: { cwd?: string; agentPreset?: string } - events: readonly SidebarSessionEvent[] - }> + open(sessionId: string, access: 'read' | 'write'): Promise +} + +/** One open channel onto a stored session (the fields this plugin reads). */ +export interface SidebarSessionHandle { + /** Immutable stored header (cwd / agentPreset live here). */ + readonly header: { cwd?: string; agentPreset?: string } & Record + /** Exact fork-inherited prefix length stored with the log. */ + readonly inheritedEventCount?: number + /** + * Read a slice of the valid contiguous log. + * @param offset - first logical seq to include (defaults to 0). + * @param length - maximum events (defaults to the rest of the log). + */ + read(offset?: number, length?: number): Promise<{ events: readonly SidebarSessionEvent[] }> + /** Release the handle (idempotent). */ + close(): Promise } /** The client session list snapshot the sidebar subscribes to. */ @@ -408,30 +428,6 @@ export interface SidebarConversation { } } -/** - * The client `remote.session` namespace face (mirror of the gateway client's - * RemoteNamespaceService for the session-controller contribution on alpha - * hosts). The chat's file-open funnel is `openWorkspacePath`: the caller - * resolves the path against the session cwd, and the host hands it to the - * OS's default application. Namespace methods are accessor properties (see - * `client/openpath-intercept.ts` for how the interception shadows them). - */ -export interface SidebarRemoteSessionService { - /** - * Open an absolute path with the Host operating system's default - * application. Resolves with the typert `RemoteResult` envelope - * (`{ ok: true, value: { opened } }` / `{ ok: false, error }`), like every - * remote method — callers branch on `result.ok`. - */ - openWorkspacePath( - request: { path: string }, - signal?: AbortSignal, - ): Promise< - | { readonly ok: true; readonly value: { opened: boolean } } - | { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly details: object } } - > -} - /** * The invariant service face (mirror of @deepseek-ai/dsh-invariants' * InvariantRegistry). The upstream augmentation does not reach this Context @@ -564,6 +560,13 @@ export interface SidebarContextShape { * LIVE Session instance that appended it. */ on(event: string, listener: (session: unknown, event: SidebarSessionEvent) => void): () => void + /** + * The agent's process-local assistant stream (DSH 0.1.5+): one payload per + * `start` / `chunk` / `end` frame, carrying the emitting agent and the + * frame. These frames are NOT session events — see + * {@link ./assistant-live.ts} for why the plugin needs them. + */ + on(event: 'agent/assistant-stream', listener: (payload: { agent?: unknown; frame?: unknown }) => void): () => void } /** diff --git a/src/index.ts b/src/index.ts index 37f2c15..7271ac1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,7 +54,9 @@ import { AgentOpenRegistry, registerOpenTool, type AgentOpenRequest } from './ag import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts' import { buildSubagentLiveApi, type SidebarSubagentLiveRoutes } from './subagent-live-route.ts' import { buildSidechatApi } from './sidechat-routes.ts' +import { createAssistantLiveBuffer, type AssistantLiveBuffer } from './assistant-live.ts' import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts' +import { readPersistedSession } from './session-store.ts' export { Config } export type { SidebarConfig, ResolvedSidebarConfig } @@ -129,8 +131,8 @@ async function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string) } const persistence = ctx.get('sessionPersistence') if (persistence !== undefined) { - const inspected = await persistence.inspect(sessionId) - const metaCwd = inspected.meta.cwd + const persisted = await readPersistedSession(persistence, sessionId) + const metaCwd = persisted.header.cwd if (metaCwd !== undefined && metaCwd !== '') { try { return requireAbsolute(metaCwd) @@ -298,6 +300,7 @@ function buildApi( resolved: ResolvedSidebarConfig, terminalShell: string, getSettings: () => SidebarSettingsFace | undefined, + assistantLive: AssistantLiveBuffer, ): Record { const cwdOf = async (payload: unknown): Promise<{ sessionId: string; cwd: string }> => { const sessionId = requireString(payload, 'sessionId') @@ -507,7 +510,7 @@ function buildApi( const persistence = ctx.get('sessionPersistence') if (persistence !== undefined) { try { - events = (await persistence.inspect(sessionId)).events + events = (await readPersistedSession(persistence, sessionId)).events } catch { // Cold read unavailable (session never persisted): an empty // window is the honest answer, not a wire error. @@ -703,7 +706,7 @@ function buildApi( // identities are fenced from the generic session RPCs (agent-lookup // ownership), and the thread is created with a CUSTOM seed the stock // fork APIs cannot express. - ...buildSidechatApi(ctx), + ...buildSidechatApi(ctx, assistantLive), } } @@ -868,7 +871,14 @@ export function apply(ctx: Context, config?: SidebarConfig): void { }) // ── JSON API ──────────────────────────────────────────────────────────── - const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, terminalShell, () => settingsFace) + // The live assistant stream buffer: DSH 0.1.5 publishes in-flight model + // deltas as process-local `agent/assistant-stream` frames instead of the + // durable `assistant/chunk` events 0.1.2 logged, so the side-chat + // transcript and the inherited in-progress snapshot read them here. The + // effect releases the listener on fiber disposal. + const assistantLive = createAssistantLiveBuffer(ctx) + ctx.effect(() => () => { assistantLive.dispose() }, 'dsh-better-sidebar: live assistant stream buffer') + const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, terminalShell, () => settingsFace, assistantLive) ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/sidebar/api', diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index 9bc87fe..d08a9aa 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -9,24 +9,17 @@ /** The user-settings namespace holding the side card preferences. */ export const SIDEBAR_PREFS_NS = 'dsh-better-sidebar' -/** User-facing side card preferences (new-conversation defaults). */ +/** User-facing side card preferences. */ export interface SidebarPrefs { - /** Whether a brand-new conversation opens the side card by default. */ - openByDefault: boolean - /** Default panel width as a percent of the window width (20–60). */ - defaultWidthPercent: number /** * Whether the sidebar auto-activates the Tasks page when the current - * conversation spawns a new subagent. Wide viewports also open the panel; - * narrow viewports prepare the tab without opening the full-screen drawer. + * conversation spawns a new subagent. */ autoOpenSubagent: boolean /** * Whether the sidebar auto-activates the Tasks page containing the * background-jobs section when a NEW job appears for the current - * conversation (any new job id, not just the first one). Wide viewports also - * open the panel; narrow viewports prepare the tab without opening the - * full-screen drawer. + * conversation (any new job id, not just the first one). */ autoOpenJobs: boolean /** @@ -63,14 +56,6 @@ export interface SidebarPrefs { * row in the Side card settings. */ bottomPanelAutoTerminal: boolean - /** - * Whether chat-side file opens (tool-row path links, the produced-files - * row, prose file mentions — every path that funnels through the client - * runtime's `remote.session.openWorkspacePath`) open in the sidebar editor - * instead of the Host OS's default application. On by default; the editor - * tab's own enable switch gates it too (both must be on for the takeover). - */ - interceptOpenPath: boolean /** * Whether the editor tab runs in merged mode: a path input replaces the * plain header and a toggleable file-tree panel (with a global name @@ -80,14 +65,6 @@ export interface SidebarPrefs { * Side card settings; off restores the pre-merge editor exactly. */ editorExplorer: boolean - /** - * Where the changes tab's "expand to a diff tab" action lands the diff: - * true (default) floats it as a free window centered on the viewport; - * false docks it into the shell's diff pane below the source panel (the - * pre-float behavior). The select lives under the changes card's gear in - * the Side card settings. - */ - changesDiffFloat: boolean /** * Whether the sidebar's filesystem routes enforce the workspace fence: * every client-supplied path must resolve (through symlinks) inside the @@ -246,11 +223,6 @@ export interface SidebarPrefs { pluginSettings: Record> } -/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */ -export const WIDTH_PERCENT_MIN = 20 -export const WIDTH_PERCENT_MAX = 60 -export const WIDTH_PERCENT_DEFAULT = 35 - /** Range contract of {@link SidebarPrefs.terminalFontSize}. */ export const TERMINAL_FONT_SIZE_MIN = 9 export const TERMINAL_FONT_SIZE_MAX = 32 @@ -267,8 +239,6 @@ export type TitleBarScheme = typeof TITLE_BAR_SCHEMES[number] /** Fallback prefs used whenever the settings document is unreachable or malformed. */ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { - openByDefault: false, - defaultWidthPercent: WIDTH_PERCENT_DEFAULT, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, @@ -276,9 +246,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT, - interceptOpenPath: true, editorExplorer: false, - changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', @@ -299,11 +267,6 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { pluginSettings: {}, } -/** Clamp one width percent into the contract range (shared by schema and client reads). */ -export function clampWidthPercent(value: number): number { - return Math.min(WIDTH_PERCENT_MAX, Math.max(WIDTH_PERCENT_MIN, Math.round(value))) -} - /** Clamp one terminal font size into the contract range (shared by schema and client reads). */ export function clampTerminalFontSize(value: number): number { return Math.min(TERMINAL_FONT_SIZE_MAX, Math.max(TERMINAL_FONT_SIZE_MIN, Math.round(value))) diff --git a/src/session-store.ts b/src/session-store.ts new file mode 100644 index 0000000..1494b96 --- /dev/null +++ b/src/session-store.ts @@ -0,0 +1,65 @@ +/** + * Cold-session reads over the host `sessionPersistence` service. + * + * DSH 0.1.5 removed the detached `inspect(id)` call: a persisted session is + * read through an explicit handle — `open(id, 'read')` never takes write + * ownership (so it works while the session is live in another process), + * `handle.read()` returns one contiguous slice of the log, and `close()` + * releases it. Every cold read in this plugin goes through + * {@link readPersistedSession} so the handle is always released, even when + * the read throws. + */ +import type { Context, SidebarSessionEvent, SidebarSessionPersistenceService } from './context-types.ts' + +/** One persisted session as this plugin reads it. */ +export interface PersistedSession { + /** Immutable stored header (cwd / agentPreset live here). */ + readonly header: { cwd?: string; agentPreset?: string } & Record + /** The full stored event log. */ + readonly events: readonly SidebarSessionEvent[] + /** Exact fork-inherited prefix length (0 when the header is not seeded). */ + readonly inheritedEventCount: number +} + +/** + * Read one persisted session's header and full event log. + * @param persistence - the live `sessionPersistence` service. + * @param sessionId - the stored session to read. + * @returns the header and log; rejects when the session does not exist. + */ +export async function readPersistedSession( + persistence: SidebarSessionPersistenceService, + sessionId: string, +): Promise { + const handle = await persistence.open(sessionId, 'read') + try { + const { events } = await handle.read() + return { + header: handle.header, + events, + inheritedEventCount: handle.inheritedEventCount ?? 0, + } + } finally { + await handle.close() + } +} + +/** + * Resolve the service and read one persisted session, or `undefined` when + * the service is absent or the session cannot be read. + * @param ctx - host plugin context. + * @param sessionId - the stored session to read. + * @returns the persisted session, or `undefined`. + */ +export async function readPersistedSessionOf( + ctx: Context, + sessionId: string, +): Promise { + const persistence = ctx.get('sessionPersistence') + if (persistence === undefined) return undefined + try { + return await readPersistedSession(persistence, sessionId) + } catch { + return undefined + } +} diff --git a/src/sidechat-core.ts b/src/sidechat-core.ts index af18208..67f108d 100644 --- a/src/sidechat-core.ts +++ b/src/sidechat-core.ts @@ -17,6 +17,7 @@ * snapshot inside the boundary prompt. */ import type { SidebarHistoryEntry, SidebarSessionSummary } from './context-types.ts' +import type { AssistantLiveChunk } from './assistant-live.ts' /** The durable thread-label prefix (also the row filter in the client list). */ export const SIDE_LABEL_PREFIX = 'Side: ' @@ -81,6 +82,55 @@ export interface SidechatLogEvent { data: unknown } +/** + * One live assistant delta on the plugin's wire, mirroring DSH 0.1.5's + * client-only `assistant/live-chunk` presentation row. + * + * DSH 0.1.5 no longer logs `assistant/chunk`: an in-flight attempt's deltas + * are process-local frames (`agent/assistant-stream`, folded by + * {@link ./assistant-live.ts}) and reach the transcript through the plugin's + * own `sidechat.events` route as these rows. They are NOT durable — the + * route returns the current attempt's rows on every poll and the client + * replaces its live set each time; the durable `assistant/message` settles + * them by `turn:step`. + */ +export interface SidechatLiveEvent { + type: 'assistant/live-chunk' + /** Ordering key among live rows only; durable seqs stay authoritative. */ + seq: number + time: number + data: { + attemptId: string + turn: number + step: number + /** Dense zero-based position within the attempt. */ + index: number + /** The raw model stream chunk. */ + chunk: Record + } +} + +/** + * Project buffered live chunks into wire rows. + * @param chunks - the session's active-attempt chunks, in index order. + * @param tailSeq - the session's last durable seq (live rows order after it). + * @returns the rows to append to the transcript feed. + */ +export function liveEventsOf(chunks: readonly AssistantLiveChunk[], tailSeq: number): SidechatLiveEvent[] { + return chunks.map((delta, position) => ({ + type: 'assistant/live-chunk', + seq: tailSeq + 1 + position, + time: delta.time, + data: { + attemptId: delta.attemptId, + turn: delta.turn, + step: delta.step, + index: delta.index, + chunk: delta.chunk, + }, + })) +} + /** The result of cutting a parent log into a side-thread inheritance. */ export interface SidechatInheritance { /** The child seed: contiguous from seq 0, ends outside any open turn. */ @@ -210,8 +260,14 @@ const SNAPSHOT_TOTAL_CAP = 8000 /** * Build the side-thread inheritance for one parent log: the full event log * up to the click moment, honestly closed when it ends inside an open turn. + * @param events - the parent's log (live or persisted). + * @param live - the parent's in-flight stream chunks (DSH 0.1.5+ publishes + * them outside the log); used only by the snapshot fallback. */ -export function buildSidechatInheritance(events: readonly SidechatLogEvent[]): SidechatInheritance { +export function buildSidechatInheritance( + events: readonly SidechatLogEvent[], + live: readonly AssistantLiveChunk[] = [], +): SidechatInheritance { if (events.length === 0) return { seed: [], snapshot: null } const boundary = lastTurnBoundary(events) if (boundary < 0 || events[boundary]?.type === 'turn/end') { @@ -225,7 +281,7 @@ export function buildSidechatInheritance(events: readonly SidechatLogEvent[]): S // the structured snapshot to the boundary prompt instead. return { seed: copyEvents(events.slice(0, boundary)), - snapshot: buildOpenTurnSnapshot(events), + snapshot: buildOpenTurnSnapshot(events, live), } } const seed = copyEvents(events) @@ -245,16 +301,79 @@ export function buildSidechatInheritance(events: readonly SidechatLogEvent[]): S return { seed, snapshot: null } } +/** One assistant content block reduced to the text the snapshot shows. */ +function messageTexts(message: unknown): { text: string; reasoning: string } { + const content = (message as { content?: unknown } | undefined)?.content + let text = '' + let reasoning = '' + if (!Array.isArray(content)) return { text, reasoning } + for (const block of content) { + if (block === null || typeof block !== 'object') continue + const candidate = block as { type?: unknown; text?: unknown } + if (typeof candidate.text !== 'string' || candidate.text === '') continue + if (candidate.type === 'text') text += candidate.text + else if (candidate.type === 'reasoning') reasoning += candidate.text + } + return { text, reasoning } +} + +/** + * Expand one attempt's compact `AssistantStreamRecord[]` (the durable stream + * DSH 0.1.5 embeds in `assistant/message.stream` and `assistant/attempt.stream`) + * into the text/reasoning it carried. Tool-call records contribute no text. + */ +function streamTexts(stream: unknown): { text: string; reasoning: string } { + let text = '' + let reasoning = '' + if (!Array.isArray(stream)) return { text, reasoning } + for (const record of stream) { + if (record === null || typeof record !== 'object') continue + const entry = record as { type?: unknown; texts?: unknown; chunk?: unknown } + if (entry.type === 'text-chunks' || entry.type === 'reasoning-chunks') { + if (!Array.isArray(entry.texts)) continue + const joined = entry.texts.filter((part): part is string => typeof part === 'string').join('') + if (entry.type === 'text-chunks') text += joined + else reasoning += joined + continue + } + if (entry.type === 'chunk') { + const chunk = entry.chunk as { type?: unknown; text?: unknown } | undefined + if (chunk === null || typeof chunk !== 'object' || typeof chunk.text !== 'string') continue + if (chunk.type === 'text-delta') text += chunk.text + else if (chunk.type === 'reasoning-delta') reasoning += chunk.text + } + } + return { text, reasoning } +} + +/** One live delta's contribution to the snapshot. */ +function liveTexts(chunk: Record): { text: string; reasoning: string } { + if (typeof chunk.text !== 'string' || chunk.text === '') return { text: '', reasoning: '' } + if (chunk.type === 'text-delta') return { text: chunk.text, reasoning: '' } + if (chunk.type === 'reasoning-delta') return { text: '', reasoning: chunk.text } + return { text: '', reasoning: '' } +} + /** * Structured text snapshot of the parent's OPEN turn (from its `turn/start` - * to the log tail): the accumulated assistant/reasoning output verbatim - * (code blocks ride the raw deltas) and the tool activity — executed tools - * with their result text, the still-executing one marked. Returns null when - * there is no open turn or nothing to show. + * to the log tail): the assistant/reasoning output so far and the tool + * activity — executed tools with their result text, the still-executing one + * marked. Returns null when there is no open turn or nothing to show. + * + * The in-flight step's text is NOT in the log on DSH 0.1.5 (the model stream + * is process-local until it settles), so it comes from `live`; settled steps + * read their durable `assistant/message` content, and a failed attempt reads + * its embedded `assistant/attempt.stream`. + * @param events - the parent's log. + * @param live - the parent's in-flight stream chunks, in index order. */ -export function buildOpenTurnSnapshot(events: readonly SidechatLogEvent[]): string | null { +export function buildOpenTurnSnapshot( + events: readonly SidechatLogEvent[], + live: readonly AssistantLiveChunk[] = [], +): string | null { const boundary = lastTurnBoundary(events) if (boundary < 0 || events[boundary]?.type !== 'turn/start') return null + const openTurn = numberAt(dataOf(events[boundary]!), 'turn') let text = '' let reasoning = '' const tools: string[] = [] @@ -267,11 +386,16 @@ export function buildOpenTurnSnapshot(events: readonly SidechatLogEvent[]): stri pendingCalls.clear() continue } - if (event.type === 'assistant/chunk') { - const chunk = data.chunk as { type?: unknown; text?: unknown } | undefined - if (chunk === null || typeof chunk !== 'object') continue - if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text - else if (chunk.type === 'reasoning-delta' && typeof chunk.text === 'string') reasoning += chunk.text + if (event.type === 'assistant/message') { + const settled = messageTexts(data.message) + text += settled.text + reasoning += settled.reasoning + continue + } + if (event.type === 'assistant/attempt') { + const attempt = streamTexts(data.stream) + text += attempt.text + reasoning += attempt.reasoning continue } if (event.type === 'tool/call') { @@ -304,6 +428,13 @@ export function buildOpenTurnSnapshot(events: readonly SidechatLogEvent[]): stri const line = `- \`${call.name}\` (executing) — arguments: \`${call.args}\`` tools.push(line) } + // The in-flight step: its deltas never reached the log. + for (const delta of live) { + if (delta.turn !== openTurn) continue + const contribution = liveTexts(delta.chunk) + text += contribution.text + reasoning += contribution.reasoning + } const sections: string[] = [] if (text.trim() !== '') sections.push(`Assistant output so far:\n\n${text}`) if (reasoning.trim() !== '') sections.push(`Reasoning so far:\n\n${reasoning}`) diff --git a/src/sidechat-routes.ts b/src/sidechat-routes.ts index a0252b3..9d60df4 100644 --- a/src/sidechat-routes.ts +++ b/src/sidechat-routes.ts @@ -37,17 +37,21 @@ import type { import { boundaryDelivered, buildSidechatInheritance, + liveEventsOf, resolvePresetId, SIDE_BOUNDARY_PROMPT, SIDE_INJECTION_PLUGIN, SIDE_NEW_THREAD_TITLE, sideLabel, type SeedEvent, + type SidechatLiveEvent, type SidechatLogEvent, type SidechatThreadInfo, threadOwnLogEvents, } from './sidechat-core.ts' +import type { AssistantLiveBuffer } from './assistant-live.ts' import { requireString, SidebarError } from './wire.ts' +import { readPersistedSession } from './session-store.ts' /** The six Side Chat routes of the sidebar API (wire method names). */ export interface SidechatRoutes { @@ -66,8 +70,10 @@ export interface SidechatRoutes { 'sidechat.info'(payload: unknown): Promise /** The thread's OWN transcript events, seed-cut host-side (the inherited * parent log never crosses the wire); `afterSeq` narrows the response to - * the delta beyond it (poll tail). */ - 'sidechat.events'(payload: unknown): Promise<{ events: SidechatLogEvent[] }> + * the delta beyond it (poll tail). `live` carries the thread's in-flight + * model deltas, which DSH 0.1.5 publishes outside the session log — it is + * the CURRENT attempt's rows on every poll, never a delta. */ + 'sidechat.events'(payload: unknown): Promise<{ events: SidechatLogEvent[]; live: SidechatLiveEvent[] }> } /** Timeout guarding the create call (the registry detaches it before the @@ -117,8 +123,8 @@ async function composePersistedSetup( if (persistence === undefined) { return () => Promise.resolve() } - const inspected = await persistence.inspect(childId) - const presetId = resolvePresetId(inspected.meta, inspected.events) + const inspected = await readPersistedSession(persistence, childId) + const presetId = resolvePresetId(inspected.header, inspected.events) const presets = ctx.get('agentPresets') as SidebarAgentPresetsService | undefined if (presets === undefined || presetId === undefined) { return () => Promise.resolve() @@ -186,7 +192,7 @@ async function threadLogEvents(ctx: Context, childId: string): Promise { const sessionId = requireString(payload, 'sessionId') @@ -213,6 +222,7 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes { const parentSession = parent.session const inheritance = buildSidechatInheritance( parentSession.snapshotEvents() as unknown as readonly SidechatLogEvent[], + live?.chunksFor(sessionId) ?? [], ) const { agentPreset, setup } = await composeChildSetup( ctx, @@ -388,8 +398,8 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes { const persistence = ctx.get('sessionPersistence') as SidebarSessionPersistenceService | undefined if (persistence !== undefined) { try { - const inspected = await persistence.inspect(childId) - const preset = resolvePresetId(inspected.meta, inspected.events) + const inspected = await readPersistedSession(persistence, childId) + const preset = resolvePresetId(inspected.header, inspected.events) return { live: false, ...(preset === undefined ? {} : { preset }) } } catch { // Unknown/gone session: report a bare cold info. @@ -408,7 +418,13 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes { const events = await threadLogEvents(ctx, childId) const own = threadOwnLogEvents(events) const fresh = rawAfter === undefined ? own : own.filter(event => event.seq > rawAfter) - return { events: fresh.length > EVENTS_CAP ? fresh.slice(fresh.length - EVENTS_CAP) : fresh } + const tailSeq = own.at(-1)?.seq ?? -1 + return { + events: fresh.length > EVENTS_CAP ? fresh.slice(fresh.length - EVENTS_CAP) : fresh, + // The live rows ride every poll: they are process-local, so there is + // no durable seq to page them by. An idle thread has none. + live: liveEventsOf(live?.chunksFor(childId) ?? [], tailSeq), + } }, } } diff --git a/src/subagent-activity.ts b/src/subagent-activity.ts index 6a5d582..d44d07d 100644 --- a/src/subagent-activity.ts +++ b/src/subagent-activity.ts @@ -39,9 +39,11 @@ export interface LastActivity { /** * Fold a session event log into the last text output + last tool call (each - * is the LAST occurrence in event order). Lifecycle events and raw - * `assistant/chunk` rows are ignored — the card shows what the subagent is - * doing right now, not its plumbing. The scan runs BACKWARD from the newest + * is the LAST occurrence in event order). Lifecycle events and raw stream + * rows are ignored — the card shows what the subagent is doing right now, + * not its plumbing. (DSH 0.1.5 publishes the raw deltas as process-local + * frames instead of log events, so only the assembled `assistant/message` + * reaches this scan.) The scan runs BACKWARD from the newest * event and stops once both fields are found, so a long history costs only * the recent tail in the common case. * @param events - the session's append-only event log (oldest → newest). diff --git a/tests/agent-terminal-reconcile.spec.ts b/tests/agent-terminal-reconcile.spec.ts index bc20e5e..5399f66 100644 --- a/tests/agent-terminal-reconcile.spec.ts +++ b/tests/agent-terminal-reconcile.spec.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest' import { - allLeaves, makeDefaultState, openTabInActivePane, reconcileAgentTerminals, + allLeaves, makeDefaultState, openTabInBottomPane, reconcileAgentTerminals, setTabPin, tabOpenIn, toggleBottomPanel, } from '../src/client/state.ts' describe('agent terminal reconciliation', () => { it('adds tabs for new agent terminals', () => { - let s = makeDefaultState(400, true) + let s = makeDefaultState() s = reconcileAgentTerminals(s, [ { uuid: 'aaa-111', title: 'dev server' }, { uuid: 'bbb-222', title: 'python repl' }, @@ -14,14 +14,14 @@ describe('agent terminal reconciliation', () => { expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true) expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true) // The titles are preserved. - const tabs = allLeaves(s.splits).flatMap(leaf => leaf.tabs) + const tabs = allLeaves(s.bottomSplits).flatMap(leaf => leaf.tabs) const agentTab = tabs.find(t => t.id === 'agent:aaa-111') expect(agentTab?.title).toBe('dev server') expect(agentTab?.type).toBe('terminal') }) it('removes tabs for agent terminals that vanished from the server list', () => { - let s = makeDefaultState(400, true) + let s = makeDefaultState() s = reconcileAgentTerminals(s, [ { uuid: 'aaa-111', title: 'keep' }, { uuid: 'bbb-222', title: 'remove' }, @@ -34,22 +34,22 @@ describe('agent terminal reconciliation', () => { }) it('is a no-op (same reference) when the lists already match', () => { - let s = makeDefaultState(400, true) + let s = makeDefaultState() s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }]) const next = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }]) expect(next).toBe(s) }) it('does not touch non-agent terminal tabs', () => { - let s = makeDefaultState(400, true) - s = openTabInActivePane(s, { id: 'terminal:1', type: 'terminal', title: 'UI Tab' }) + let s = makeDefaultState() + s = openTabInBottomPane(s, { id: 'terminal:1', type: 'terminal', title: 'UI Tab' }) s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'agent' }]) expect(tabOpenIn(s, 'terminal:1')).toBe(true) expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true) }) it('handles an empty server list (removes all agent tabs)', () => { - let s = makeDefaultState(400, true) + let s = makeDefaultState() s = reconcileAgentTerminals(s, [ { uuid: 'aaa-111', title: 'a' }, { uuid: 'bbb-222', title: 'b' }, @@ -59,20 +59,19 @@ describe('agent terminal reconciliation', () => { expect(tabOpenIn(s, 'agent:bbb-222')).toBe(false) }) - it('lands new agent terminals in the active tree (bottom panel pane)', () => { - let s = makeDefaultState(400, true) + it('lands new agent terminals in the workbench pane', () => { + let s = makeDefaultState() s = toggleBottomPanel(s) - const bottomPane = (s.bottomSplits as { id: string }).id - s = { ...s, activePane: bottomPane } + const pane = (s.bottomSplits as { id: string }).id + s = { ...s, activePane: pane } s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'dev server' }]) expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true) expect(allLeaves(s.bottomSplits).flatMap(l => l.tabs).some(t => t.id === 'agent:aaa-111')).toBe(true) - expect(allLeaves(s.splits).flatMap(l => l.tabs).some(t => t.id === 'agent:aaa-111')).toBe(false) }) describe('pinned agent terminal exemption (v0.17.0)', () => { it('keeps a pinned agent tab when its uuid vanishes from the server list', () => { - let s = makeDefaultState(400, true) + let s = makeDefaultState() s = reconcileAgentTerminals(s, [ { uuid: 'aaa-111', title: 'keep' }, { uuid: 'bbb-222', title: 'pinned' }, @@ -84,13 +83,13 @@ describe('agent terminal reconciliation', () => { expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true) expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true) // The pin marker is intact. - const tabs = allLeaves(s.splits).flatMap(l => l.tabs) + const tabs = allLeaves(s.bottomSplits).flatMap(l => l.tabs) const pinned = tabs.find(t => t.id === 'agent:bbb-222')! expect(pinned.pin).toEqual({ scope: 'global' }) }) it('still removes an unpinned agent tab when the uuid vanishes', () => { - let s = makeDefaultState(400, true) + let s = makeDefaultState() s = reconcileAgentTerminals(s, [ { uuid: 'aaa-111', title: 'pinned' }, { uuid: 'bbb-222', title: 'unpinned' }, @@ -103,7 +102,7 @@ describe('agent terminal reconciliation', () => { }) it('a pinned tab whose uuid RETURNS in a later push is a no-op (already exists)', () => { - let s = makeDefaultState(400, true) + let s = makeDefaultState() s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'gone' }]) s = setTabPin(s, 'agent:aaa-111', { scope: 'global' }) // First push without it: exempted, stays. @@ -117,7 +116,7 @@ describe('agent terminal reconciliation', () => { }) it('exemption does not block the toAdd path (new uuids still land)', () => { - let s = makeDefaultState(400, true) + let s = makeDefaultState() s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'first' }]) s = setTabPin(s, 'agent:aaa-111', { scope: 'global' }) // aaa-111 vanishes (exempted), bbb-222 is brand-new. diff --git a/tests/agent-wait-badge.spec.tsx b/tests/agent-wait-badge.spec.tsx index 5dd4cf2..fbb73ad 100644 --- a/tests/agent-wait-badge.spec.tsx +++ b/tests/agent-wait-badge.spec.tsx @@ -15,7 +15,7 @@ import { setupReactAct } from './test-utils.ts' setupReactAct() import { Sidebar } from '../src/client/Sidebar.tsx' -import { createSidebarStore, openTabInActivePane } from '../src/client/state.ts' +import { createSidebarStore, openTabInBottomPane } from '../src/client/state.ts' import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts' /** jsdom has no WebSocket; the agent-terminals push effect constructs one on mount. */ @@ -119,7 +119,7 @@ describe('agent terminal wait badge (push → state → tab pill)', () => { const { container, store } = mountSidebar() // A UI-owned terminal tab (id NOT agent:) lives in the strip too; the // badge lookup keys on the agent uuid, so it never badges. - act(() => { store.reduce(s => openTabInActivePane(s, { id: 'terminal:manual-1', type: 'terminal', title: 'UI terminal' })) }) + act(() => { store.reduce(s => openTabInBottomPane(s, { id: 'terminal:manual-1', type: 'terminal', title: 'UI terminal' })) }) const terminal = { uuid: 'u1', title: 'dev server', command: '', exited: false } act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([{ ...terminal, waiting: { needle: 'READY_1', since: 1 } }]) }) }) // Exactly ONE hourglass: the agent tab. (Counted from textContent — CSS diff --git a/tests/assistant-live.spec.ts b/tests/assistant-live.spec.ts new file mode 100644 index 0000000..581ea81 --- /dev/null +++ b/tests/assistant-live.spec.ts @@ -0,0 +1,132 @@ +/** + * Unit tests for the live assistant stream buffer (src/assistant-live.ts): + * the fold that replaces DSH 0.1.2's durable `assistant/chunk` events with + * the process-local `agent/assistant-stream` frames 0.1.5 publishes instead. + */ +import { describe, expect, it, vi } from 'vitest' +import { createAssistantLiveBuffer, LIVE_CHUNK_CAP, LIVE_SESSION_CAP } from '../src/assistant-live.ts' +import type { Context } from '../src/context-types.ts' + +/** A context whose `on` records the stream listener the buffer installs. */ +function harness(): { + ctx: Context + emit: (payload: unknown) => void + off: ReturnType +} { + let listener: ((payload: unknown) => void) | undefined + const off = vi.fn() + const ctx = { + on: (event: string, fn: (payload: unknown) => void) => { + expect(event).toBe('agent/assistant-stream') + listener = fn + return off + }, + } as unknown as Context + return { ctx, emit: payload => { listener?.(payload) }, off } +} + +/** One frame as DSH emits it, for the session named by `sessionId`. */ +function frame(sessionId: string, frame: Record): Record { + return { agent: { session: { id: sessionId } }, frame } +} + +describe('createAssistantLiveBuffer', () => { + it('folds start + chunk frames into ordered per-session chunks', () => { + const { ctx, emit } = harness() + const live = createAssistantLiveBuffer(ctx) + emit(frame('s1', { type: 'start', attemptId: 'a1', revision: 1, turn: 2, step: 1 })) + emit(frame('s1', { type: 'chunk', attemptId: 'a1', revision: 1, index: 0, time: 10, chunk: { type: 'text-delta', index: 0, text: 'he' } })) + emit(frame('s1', { type: 'chunk', attemptId: 'a1', revision: 1, index: 1, time: 11, chunk: { type: 'reasoning-delta', index: 1, text: 'hmm' } })) + const chunks = live.chunksFor('s1') + expect(chunks.map(chunk => [chunk.turn, chunk.step, chunk.index, chunk.time])).toEqual([ + [2, 1, 0, 10], + [2, 1, 1, 11], + ]) + expect(chunks[1]?.chunk).toEqual({ type: 'reasoning-delta', index: 1, text: 'hmm' }) + expect(live.chunksFor('other')).toEqual([]) + }) + + it('ignores payloads it cannot identify', () => { + const { ctx, emit } = harness() + const live = createAssistantLiveBuffer(ctx) + emit(undefined) + emit({}) + emit({ agent: {}, frame: { type: 'chunk', attemptId: 'a1', index: 0, chunk: {} } }) + emit({ agent: { session: { id: 's1' } }, frame: { type: 'chunk', attemptId: 'a1', index: 0, chunk: {} } }) + // A chunk with no start is not an attempt this buffer knows. + expect(live.chunksFor('s1')).toEqual([]) + emit(frame('s1', { type: 'start', attemptId: 'a1', turn: 1, step: 1 })) + emit(frame('s1', { type: 'chunk', attemptId: 'a1', index: 0, chunk: 'not-a-record' })) + expect(live.chunksFor('s1')).toEqual([]) + }) + + it('drops the attempt on a gap rather than splicing incomplete text', () => { + const { ctx, emit } = harness() + const live = createAssistantLiveBuffer(ctx) + emit(frame('s1', { type: 'start', attemptId: 'a1', turn: 1, step: 1 })) + emit(frame('s1', { type: 'chunk', attemptId: 'a1', index: 0, chunk: { type: 'text-delta', text: 'a' } })) + emit(frame('s1', { type: 'chunk', attemptId: 'a1', index: 2, chunk: { type: 'text-delta', text: 'c' } })) + expect(live.chunksFor('s1')).toEqual([]) + }) + + it('drops the attempt when a chunk names another attempt', () => { + const { ctx, emit } = harness() + const live = createAssistantLiveBuffer(ctx) + emit(frame('s1', { type: 'start', attemptId: 'a1', turn: 1, step: 1 })) + emit(frame('s1', { type: 'chunk', attemptId: 'a2', index: 0, chunk: { type: 'text-delta', text: 'a' } })) + expect(live.chunksFor('s1')).toEqual([]) + }) + + it('clears the buffer when the attempt ends', () => { + const { ctx, emit } = harness() + const live = createAssistantLiveBuffer(ctx) + emit(frame('s1', { type: 'start', attemptId: 'a1', turn: 1, step: 1 })) + emit(frame('s1', { type: 'chunk', attemptId: 'a1', index: 0, chunk: { type: 'text-delta', text: 'a' } })) + emit(frame('s1', { type: 'end', attemptId: 'a1', index: 1, outcome: { kind: 'abandoned' } })) + expect(live.chunksFor('s1')).toEqual([]) + }) + + it('a new start replaces the previous attempt of the same session', () => { + const { ctx, emit } = harness() + const live = createAssistantLiveBuffer(ctx) + emit(frame('s1', { type: 'start', attemptId: 'a1', turn: 1, step: 1 })) + emit(frame('s1', { type: 'chunk', attemptId: 'a1', index: 0, chunk: { type: 'text-delta', text: 'old' } })) + emit(frame('s1', { type: 'start', attemptId: 'a2', turn: 1, step: 2 })) + expect(live.chunksFor('s1')).toEqual([]) + emit(frame('s1', { type: 'chunk', attemptId: 'a2', index: 0, chunk: { type: 'text-delta', text: 'new' } })) + expect(live.chunksFor('s1').map(chunk => chunk.chunk['text'])).toEqual(['new']) + }) + + it('caps one attempt at LIVE_CHUNK_CAP chunks (oldest dropped)', () => { + const { ctx, emit } = harness() + const live = createAssistantLiveBuffer(ctx) + emit(frame('s1', { type: 'start', attemptId: 'a1', turn: 1, step: 1 })) + for (let index = 0; index < LIVE_CHUNK_CAP + 3; index++) { + emit(frame('s1', { type: 'chunk', attemptId: 'a1', index, chunk: { type: 'text-delta', text: String(index) } })) + } + const chunks = live.chunksFor('s1') + expect(chunks).toHaveLength(LIVE_CHUNK_CAP) + expect(chunks[0]?.index).toBe(3) + expect(chunks.at(-1)?.index).toBe(LIVE_CHUNK_CAP + 2) + }) + + it('caps tracked sessions at LIVE_SESSION_CAP (oldest dropped)', () => { + const { ctx, emit } = harness() + const live = createAssistantLiveBuffer(ctx) + for (let index = 0; index < LIVE_SESSION_CAP + 1; index++) { + emit(frame(`s${index}`, { type: 'start', attemptId: 'a1', turn: 1, step: 1 })) + } + expect(live.chunksFor('s0')).toEqual([]) + expect(live.chunksFor(`s${LIVE_SESSION_CAP}`)).toEqual([]) + // The most recent session is the only live one, and it has no chunks yet. + emit(frame(`s${LIVE_SESSION_CAP}`, { type: 'chunk', attemptId: 'a1', index: 0, chunk: { type: 'text-delta', text: 'x' } })) + expect(live.chunksFor(`s${LIVE_SESSION_CAP}`)).toHaveLength(1) + }) + + it('dispose unbinds the stream listener', () => { + const { ctx, off } = harness() + const live = createAssistantLiveBuffer(ctx) + live.dispose() + expect(off).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/builtins.spec.ts b/tests/builtins.spec.ts index f14ca3f..d763e0c 100644 --- a/tests/builtins.spec.ts +++ b/tests/builtins.spec.ts @@ -44,18 +44,44 @@ describe('built-in tab registrations', () => { expect(changes?.component).toBeDefined() }) - it('the changes tab declares the diff-open picker (free window default)', () => { + it('every visible tab declares a non-empty, mutually distinct title', () => { + // The title is the tab's identity in the native new-tab list (guide + // page) and the tab strip: it must exist, and no two visible tabs may + // share one (identical capsules would be indistinguishable). const { service } = setup() - const toggles = service.getTab('git')?.settings?.toggles ?? [] - expect(toggles.map(t => t.key)).toEqual(['changesDiffFloat']) - const picker = toggles[0] - expect(picker?.type).toBe('select') - expect(picker?.title).toBeDefined() - expect(picker?.desc).toBeDefined() - const options = picker?.options ?? [] - // Float first (the default the reducer and prefs ship), pane second. - expect(options.map(o => o.value)).toEqual([true, false]) - expect(options.every(o => o.icon !== undefined && o.title !== undefined && o.desc !== undefined)).toBe(true) + const visible = service.getTabs().filter(descriptor => descriptor.hidden !== true) + expect(visible.length).toBeGreaterThan(0) + for (const descriptor of visible) { + const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title + expect(title, `${descriptor.id} must declare a title`).toBeTruthy() + } + const titles = visible.map(descriptor => + typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title) + expect(new Set(titles).size, 'titles must differ per tab').toBe(visible.length) + }) + + it('every visible tab declares a non-empty, mutually distinct description', () => { + // DSH 0.1.5-rc.1 renders `description` under the title while the guide + // lists at most 4 entries (a longer list drops every description). With + // the host no longer substituting a generic fallback, a tab without one + // renders the title alone — so every visible tab declares the real + // purpose of its page, and no two may read identically. + const { service } = setup() + const visible = service.getTabs().filter(descriptor => descriptor.hidden !== true) + expect(visible.length).toBeGreaterThan(0) + for (const descriptor of visible) { + expect(descriptor.description, `${descriptor.id} must declare a description`).toBeDefined() + const line = typeof descriptor.description === 'function' ? descriptor.description() : descriptor.description + expect(line, `${descriptor.id} description must be non-empty`).toBeTruthy() + } + const lines = visible.map(descriptor => + typeof descriptor.description === 'function' ? descriptor.description() : descriptor.description) + expect(new Set(lines).size, 'descriptions must differ per tab').toBe(visible.length) + }) + + it('the changes tab declares no settings of its own (the diff always docks)', () => { + const { service } = setup() + expect(service.getTab('git')?.settings).toBeUndefined() }) it('only diff is hidden from the + menu; editor is the visible files window (order 10)', () => { @@ -172,7 +198,7 @@ describe('built-in tab registrations', () => { service.openTab({ type: 'browser' }) service.openTab({ type: 'browser' }) const state = store.getSnapshot().state! - const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'browser') + const tabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'browser') expect(tabs).toHaveLength(2) expect(tabs[0]!.id).toBe('browser:1') expect(tabs[1]!.id).toBe('browser:2') @@ -185,7 +211,7 @@ describe('built-in tab registrations', () => { service.openTab({ type: 'terminal' }) service.openTab({ type: 'terminal' }) const state = store.getSnapshot().state! - const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'terminal') + const tabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'terminal') expect(tabs).toHaveLength(2) expect(tabs[0]!.title).toBe('bash') expect(tabs[1]!.title).toBe('bash') @@ -199,7 +225,7 @@ describe('built-in tab registrations', () => { store.setSession('s1') service.openTab({ type: 'terminal' }) const state = store.getSnapshot().state! - const tab = allLeaves(state.splits).flatMap(leaf => leaf.tabs).find(t => t.type === 'terminal') + const tab = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs).find(t => t.type === 'terminal') expect(tab?.title).toBe(t('terminal')) }) diff --git a/tests/center-column-locator.spec.ts b/tests/center-column-locator.spec.ts index 026ab8b..cdf55db 100644 --- a/tests/center-column-locator.spec.ts +++ b/tests/center-column-locator.spec.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { CENTER_COLUMN_REVALIDATE_MS, resolveCenterColumn, @@ -20,6 +20,43 @@ function connectedColumn(doc = document.implementation.createHTMLDocument()): Fi return { doc, col, slot } } +// alpha.2 fixture: a real #root in the jsdom global document, because the +// contents-skip walk needs a live defaultView.getComputedStyle (inline +// `display: contents` on the wrapper). Build: +// #root > col > wrapper[display: contents] > host[data-slot="main.conversation"]. +function alphaColumn(hostSlot: string): { col: HTMLDivElement; host: HTMLDivElement } { + const root = document.createElement('div') + root.id = 'root' + const col = document.createElement('div') + const wrapper = document.createElement('div') + wrapper.style.display = 'contents' + const host = document.createElement('div') + host.dataset.slot = hostSlot + wrapper.append(host) + col.append(wrapper) + root.append(col) + document.body.append(root) + return { col, host } +} + +// The alpha fixtures append into the shared jsdom document; drop them so +// later tests' querySelector scans stay deterministic. +afterEach(() => { + for (const root of document.body.querySelectorAll('div#root')) root.remove() +}) + +describe('center-column anchor keys (DSH 0.1.5-alpha.2)', () => { + it('skips display:contents slot hosts above a main.conversation host', () => { + const { col } = alphaColumn('main.conversation') + expect(resolveCenterColumn(null, { now: () => 0 })).toBe(col) + }) + + it('still resolves the alpha.1 conversation key through the default selector', () => { + const { col } = alphaColumn('conversation') + expect(resolveCenterColumn(null, { now: () => 0 })).toBe(col) + }) +}) + describe('center-column locator (issue #403)', () => { it('reuses a connected cached column without querying during the hot-path window', () => { const { doc, col, slot } = connectedColumn() diff --git a/tests/consumer-types.ts b/tests/consumer-types.ts index 19fe249..028097a 100644 --- a/tests/consumer-types.ts +++ b/tests/consumer-types.ts @@ -46,7 +46,7 @@ const tab: TabDescriptor = { icon: (_size: number) => null, order: 50, hidden: false, - available: (ctx, scope, state) => scope.sessionId !== '' && state.panelOpen && ctx !== null, + available: (ctx, scope, state) => scope.sessionId !== '' && state.bottomOpen && ctx !== null, single: false, dedupeKey: (t: SidebarTab) => t.id, createTab: (state: SidebarState) => ({ diff --git a/tests/e2e/desktop-layout.e2e.ts b/tests/e2e/desktop-layout.e2e.ts deleted file mode 100644 index 0c8d797..0000000 --- a/tests/e2e/desktop-layout.e2e.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** Desktop layout regression: opening the right panel must not make Harness enter narrow mode. */ -import { mkdirSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' -import { expect, test, type Page } from '@playwright/test' -import { PAGE_URL } from './host' - -const WORKSPACE_PATH = process.env.DSH_E2E_DESKTOP_WORKSPACE - ?? (process.env.DSH_E2E_WORKSPACE === undefined - ? join(homedir(), 'dsh-e2e-desktop-layout-workspace') - : `${process.env.DSH_E2E_WORKSPACE}-desktop-layout`) - -async function dismissOnboarding(page: Page): Promise { - await expect - .poll(() => page.getByRole('button', { name: /^(Continue|Configure later|继续|稍后配置)$/ }).count(), { timeout: 60_000 }) - .toBeGreaterThan(0) - for (let round = 0; round < 8; round++) { - let dismissed = false - for (const name of [/^(Continue|继续)$/, /^(Configure later|稍后配置)$/]) { - const button = page.getByRole('button', { name }).first() - if ((await button.count()) === 0) continue - await button.click() - dismissed = true - await expect(button).toBeHidden() - } - if (!dismissed) return - } - throw new Error('onboarding takeovers did not settle after visible dismissal') -} - -test.beforeAll(() => { - mkdirSync(WORKSPACE_PATH, { recursive: true }) -}) - -test('right panel keeps desktop session actions in their header positions', async ({ page }) => { - const pageErrors: string[] = [] - const consoleErrors: string[] = [] - const requestFailures: string[] = [] - const badResponses: string[] = [] - page.on('pageerror', error => pageErrors.push(error.stack ?? error.message)) - page.on('console', message => { if (message.type() === 'error') consoleErrors.push(message.text()) }) - page.on('requestfailed', request => requestFailures.push(`${request.method()} ${request.url()} ${request.failure()?.errorText ?? 'unknown'}`)) - page.on('response', response => { if (response.status() >= 400) badResponses.push(`${response.status()} ${response.url()}`) }) - - await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) - await dismissOnboarding(page) - - await page.getByRole('button', { name: /^(Choose workspace|选择工作区)$/ }).click() - const picker = page.getByRole('dialog', { name: /^(Select Workspace Directory|选择工作区目录)$/ }) - await expect(picker).toBeVisible() - await picker.getByRole('button', { name: /^(Edit path|编辑路径)$/ }).click() - const pathInput = picker.getByRole('textbox', { name: /^(Edit path|编辑路径)$/ }) - await pathInput.fill(WORKSPACE_PATH) - await pathInput.press('Enter') - await expect(pathInput).toBeHidden() - await picker.getByRole('button', { name: /^(Open|打开)$/ }).click() - - // Target the ACTIVE composer by its accessible name. DSH 0.1.2-alpha hosts - // keep an INERT `data-composer-input` "Choose workspace" textbox (with - // contenteditable="false") later in DOM order than the real composer — a - // bare `getByRole('textbox').last()` resolved to that one and fill() threw - // ("not an ,