Compare commits

...
6 Commits
Author SHA1 Message Date
Menghuan1918 2e9db44a71 chore: v0.9.0 — 版本升级(适配 DSH 命名契约重命名) 2026-08-13 09:07:19 +08:00
Menghuan1918 1bfabbbfc2 feat: 适配 DSH 命名契约重命名(jobs/terminal/webServer)并处理 Session log 按钮位置
- tasks→jobs:ctx.jobs / job_output / job_id / session/jobs / jobsBySession / Job* 词汇
- httpServer→webServer(inject + augmentation + register/registerUpgrade)
- autoOpenTasks→autoOpenJobs 偏好键
- 右上角开关簇回到角落,注入 CSS 挤开 header 的「Session log」胶囊
2026-08-13 09:06:55 +08:00
Menghuan1918 ce052faee8 feat: 预览区划选文字浮窗「添加到对话」(md 预览 + 兜底 code 查看器) 2026-08-13 01:39:59 +08:00
Menghuan1918 a2bb04bce0 fix: 底栏宽度直测中间列,修复启动竞态下底栏跑到最左边
- 底栏 left/right 由「appSidebarWidth + detailsWidth + 面板宽反推」改为直接测量 AppFrame 中间列的 bounding rect(唯一真源,展开/收起动画期间跟随 margin-right 挤压)
- 拖动期间暂停测量(avoid per-frame 重渲染卡顿),拖动结束显式重测
- #root MutationObserver 自愈启动竞态:旧实现一次查询失败后永远卡在零尺寸回退(表现为 1px 细条贴在视口最左)
2026-08-13 01:17:01 +08:00
Menghuan1918 422d5e3c10 chore: v0.8.2 — 版本升级(跟随 DSH 的 i18n) 2026-08-13 00:43:36 +08:00
Menghuan1918 9c5293c94b feat: 界面文案跟随 DSH 的 i18n(ctx.locale 语言偏好、实时切换、词典入注册表) 2026-08-13 00:43:28 +08:00
40 changed files with 1430 additions and 729 deletions
+2 -1
View File
@@ -10,7 +10,7 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context
- **禁止修改 DeepSeek Harness (DSH) 源码**:对官方源码 checkout(`~/.dsh/source/current`)零写入——不得改 harness 包、不得把 harness 改动提交到它的分支。 - **禁止修改 DeepSeek Harness (DSH) 源码**:对官方源码 checkout(`~/.dsh/source/current`)零写入——不得改 harness 包、不得把 harness 改动提交到它的分支。
- **挂载只走 `cordis.patch.yml` + profile 机制**(`~/.dsh/profiles/<profile>/`),插件永远作为独立包被 profile 引用,不反向侵入 DSH。 - **挂载只走 `cordis.patch.yml` + profile 机制**(`~/.dsh/profiles/<profile>/`),插件永远作为独立包被 profile 引用,不反向侵入 DSH。
- 需要 harness 没有的能力时,用 DSH **现成的只读/公开 API** 或插件自有路由实现(参考 §7 的 `tasks.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 DSH。 - 需要 harness 没有的能力时,用 DSH **现成的只读/公开 API** 或插件自有路由实现(参考 §7 的 `jobs.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 DSH。
--- ---
@@ -407,6 +407,7 @@ interface BetterSidebarService {
| **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 | | **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 |
| **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换 | | **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换 |
| **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) | | **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) |
| **i18n 跟随** | 侧边栏界面文案跟随 DSH 的 `ctx.locale`(`@deepseek-ai/dsh-client-locale`):词典注册在 `betterSidebar` 命名空间,语言偏好(Host-backed `locale.preference`)与浏览器语言不一致时以 DSH 为准并实时切换;locale 服务缺失时回退浏览器语言。插件自身的 `t()`(`src/client/locales.ts`)由 `apply()` 挂接服务;消费插件**不要**依赖此内部函数——标题等字段传字符串或 `() => string` 即可(i18n 友好) |
| **懒加载 chunk** | 内置重依赖(Univer/xterm/CodeMirror/pptx/docx)在独立 bundle(`lib/client-<name>.js`)中,经 `/sidebar/bundle` 路由按需下发;每个脚本把 factory 赋到插件自有全局注册表 `globalThis.__dshChunks__[<name>]`,由 `src/client/chunk-loader.ts` 用自定义 require(externals 经 `__DSH_MODULES__` seed 分支解析)物化——**不经过** `__ModuleLoader__` 注册;**核心 bundle 禁止静态 import `src/client/chunks/*`**(会把库拖回启动路径);对消费插件透明——懒加载只作用于内置 descriptor,`component` 契约(`(props) => ReactNode` 纯渲染函数)不变 | | **懒加载 chunk** | 内置重依赖(Univer/xterm/CodeMirror/pptx/docx)在独立 bundle(`lib/client-<name>.js`)中,经 `/sidebar/bundle` 路由按需下发;每个脚本把 factory 赋到插件自有全局注册表 `globalThis.__dshChunks__[<name>]`,由 `src/client/chunk-loader.ts` 用自定义 require(externals 经 `__DSH_MODULES__` seed 分支解析)物化——**不经过** `__ModuleLoader__` 注册;**核心 bundle 禁止静态 import `src/client/chunks/*`**(会把库拖回启动路径);对消费插件透明——懒加载只作用于内置 descriptor,`component` 契约(`(props) => ReactNode` 纯渲染函数)不变 |
--- ---
+3 -2
View File
@@ -2,7 +2,7 @@
> 一个插件,一套完整工作台: > 一个插件,一套完整工作台:
> >
> 文件管理、编辑预览、内嵌浏览器、真实终端、Git 面板、任务管理页——**右侧栏 + 底部面板双工作台**,一个插件全部搞定。 > 文件管理、编辑预览、内嵌浏览器、真实终端、Git 面板、后台任务页——**右侧栏 + 底部面板双工作台**,一个插件全部搞定。
> >
> 支持Tab窗口随意拖拽,支持三方拓展注册新Tab页面和文件预览 > 支持Tab窗口随意拖拽,支持三方拓展注册新Tab页面和文件预览
@@ -18,13 +18,14 @@ https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
- **🌐 浏览器**:内嵌网页浏览 tab(多开),后退/前进/刷新 +「在浏览器中打开」;页面在**沙箱 iframe** 中运行(不透明源:无法访问界面数据与本地文件,拒绝 localhost 等本机地址),界面实时显示沙箱状态、可临时解锁(关闭时红色警示);被站点拒绝嵌入(X-Frame-Options)时显示原因面板;聊天/界面里的 http(s) 外链默认在侧边栏打开(侧边栏折叠时自动展开面板) - **🌐 浏览器**:内嵌网页浏览 tab(多开),后退/前进/刷新 +「在浏览器中打开」;页面在**沙箱 iframe** 中运行(不透明源:无法访问界面数据与本地文件,拒绝 localhost 等本机地址),界面实时显示沙箱状态、可临时解锁(关闭时红色警示);被站点拒绝嵌入(X-Frame-Options)时显示原因面板;聊天/界面里的 http(s) 外链默认在侧边栏打开(侧边栏折叠时自动展开面板)
- **💻 终端**:xterm.js + node-pty 真实 shell(每会话 3 个 UI 上限)、Tab 保活重连回放;可选为模型注入 8 个 `terminal_*` 工具 - **💻 终端**:xterm.js + node-pty 真实 shell(每会话 3 个 UI 上限)、Tab 保活重连回放;可选为模型注入 8 个 `terminal_*` 工具
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、懒加载历史、右键暂存/放弃/提交/还原/捡取 - **🌿 Git 面板**:真 diff + VSCode 式 diff tab、懒加载历史、右键暂存/放弃/提交/还原/捡取
- **🧩 任务管理页**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开;**同页显示后台命令**(当前树全部任务,bash/pwsh 类型徽标 + 退出码,点击查看实时输出——自动跟随底部、非消费 peek,不干扰模型的 `task_output`;两击确认可强制终止) - **🧩 后台任务页**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开;**同页显示后台任务**(当前树全部后台任务,bash/pwsh 类型徽标 + 退出码,点击查看实时输出——自动跟随底部、非消费 peek,不干扰模型的 `job_output`;两击确认可强制终止)
- **🪟 底部面板**:独立的第二个工作台(与右侧栏同类的标签页),只挤占中间 Agent 输出区、不覆盖左右侧边栏;**首次展开自动开一个新终端**(终端卡片二级设置可关);右上角 x 一键折叠 - **🪟 底部面板**:独立的第二个工作台(与右侧栏同类的标签页),只挤占中间 Agent 输出区、不覆盖左右侧边栏;**首次展开自动开一个新终端**(终端卡片二级设置可关);右上角 x 一键折叠
- **📱 移动端**:视口 < 768px(真正的移动端宽度,不对齐宿主 1024 断点)时只显示右侧栏——进入窄屏时底部面板的标签页**直接并入右侧栏标签条**,右上角只剩一枚开关,面板为全宽抽屉;新会话默认收起,聊天里点文件/外链自动展开,不挤压对话区 - **📱 移动端**:视口 < 768px(真正的移动端宽度,不对齐宿主 1024 断点)时只显示右侧栏——进入窄屏时底部面板的标签页**直接并入右侧栏标签条**,右上角只剩一枚开关,面板为全宽抽屉;新会话默认收起,聊天里点文件/外链自动展开,不挤压对话区
- **🔧 分栏工作台**:拖 Tab 拆分/合并分栏(可**跨面板拖 Tab**)、分隔线调比例;右上角持久按钮簇(底栏 + 侧拉 glyph)折叠/展开两个面板;两面板共享拐角双向拖动调节尺寸,拖动 rAF 直写 DOM 保持流畅 - **🔧 分栏工作台**:拖 Tab 拆分/合并分栏(可**跨面板拖 Tab**)、分隔线调比例;右上角持久按钮簇(底栏 + 侧拉 glyph)折叠/展开两个面板;两面板共享拐角双向拖动调节尺寸,拖动 rAF 直写 DOM 保持流畅
- **🔁 会话隔离**:布局/分栏/Tab/两面板状态按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开(面板折叠时自动展开) - **🔁 会话隔离**:布局/分栏/Tab/两面板状态按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开(面板折叠时自动展开)
- **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表渲染功能清单(小卡片网格,高亮 = 启用),每项可独立开/关;二级设置(子代理自动展开、终端工具、底部面板首展自动开终端、沙箱开关等)经齿轮按钮在原生弹窗中编辑 - **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表渲染功能清单(小卡片网格,高亮 = 启用),每项可独立开/关;二级设置(子代理自动展开、终端工具、底部面板首展自动开终端、沙箱开关等)经齿轮按钮在原生弹窗中编辑
- **🔌 服务化**:暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏 tab 与文件预览器(内置 7 tab + 9 viewer 也走同一服务,详见 [AGENTS.md](./AGENTS.md)) - **🔌 服务化**:暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏 tab 与文件预览器(内置 7 tab + 9 viewer 也走同一服务,详见 [AGENTS.md](./AGENTS.md))
- **🌏 多语言**:界面文案跟随 DSH 的语言设置(zh/en)实时切换——Host 偏好优先于浏览器语言,词典注册进 DSH 的 i18n 命名空间;切换语言无需刷新
## ⌨️ 快捷键 ## ⌨️ 快捷键
@@ -0,0 +1,60 @@
# 跟随 DSH 的 i18n(ctx.locale)设计
> 2026-08-12 · 随 v0.8.2 发布
## 背景
better-sidebar 的界面文案(约 260 处 `t()` 调用、28 个文件)此前自带 zh/en 词典并直接读
`navigator.language`(`src/client/locales.ts` 头注释曾明言"renders outside the slot
system's locale seat, so it reads the browser language directly")。问题:
- DSH 的语言偏好是 Host-backed(`locale.preference`,存于 `$DSH_HOME/settings.yaml`),
用户设置与浏览器语言不一致时侧边栏不跟随(如偏好 zh、浏览器 en 时侧边栏仍为英文)。
- 词典游离在 DSH 的 i18n 注册表之外,无法参与公共命名空间回退等机制。
DSH 的 i18n 系统(`@deepseek-ai/dsh-client-locale`):
- `ctx.locale` 服务(client-only):`getSnapshot()/subscribe()` 是 LocaleFace
(uSES 安全,revision 递增),`locale/change` 事件在切换时触发;
- `ctx.locale.register(ns, {zh, en})` 命名空间词典注册(typed 变体要求合并
`LocaleNamespaceMap`);查询链 ns → common → zh → key;
- 所有 DSH 客户端包以 `inject = [..., 'locale']` 消费。
## 设计决策
1. **结构镜像服务**(仓库惯例,`src/context-types.ts` 头注释):本插件解析在 DSH
monorepo 单一 cordis 实例之外,上游 `declare module 'cordis'` augmentation 不达此
Context。新增 `SidebarLocaleService` 结构脸(只镜像用到的切片:
`getSnapshot` / `subscribe` / `register`),merge `Context.locale`。不引入
`@deepseek-ai/dsh-client-locale` 依赖、不做 value import(client bundle 纯度门)。
2. **保留模块级 `t()`**:`t()` 被 28 个文件使用,穿 props 不可行。改为模块级 holder:
`apply()` 调 `attachLocale(ctx.locale)`,`t()`/`isZh()` 从 holder 读活跃 locale;
无 holder 时回退既有 `navigator.language` 检测(行为与改动前完全一致,也是 jsdom
测试与非标准组合的降级路径)。
3. **根级重渲染触发**:侧边栏自行 `createRoot` 挂在 slot 系统之外,拿不到 `t` seat。
在 `Sidebar.tsx` 根组件加 `useSyncExternalStore(ctx.locale.getSnapshot/subscribe)`
(该文件已用此 API 订阅 sessions/store);树内无 `React.memo`,根重渲染即整树
重渲染,所有渲染期 `t()` 求值读到新语言。事件期调用(intercept、api、subagent-tasks)
在调用时刻读当前语言,天然正确。SideCardSection 由 settings shell 渲染,shell 自身
订阅 locale revision(ui-settings-general 的 section rows 缓存含 locale revision),
自动跟随。
4. **命名空间 `'betterSidebar'`**:`'sidebar'` 已被 DSH 的 ui-sidebar 占用。注册走
非类型化单语言重载 `register(ns, locale, dict)`(DSH 内 ui-permission 同款先例);
键型安全由本地 `const en: Record<keyof typeof zh, string>` + 测试键集相等断言保证。
5. **词典注册进 DSH 注册表**:`ctx.effect` 内注册 zh/en 并返回合并 disposer
(fiber 卸载时撤销,HMR 重激活不抛 "already registered")。
6. **xlsx 预览的 Univer locale pack**:`xlsx-view.tsx` 原内联 navigator 检测改走
`isZh()`(跟随 DSH 语言;Univer 实例在挂载时创建,语言切换不重建实例——可接受)。
## 实施偏差记录
- 无。计划与实施一致。
- 版本号随 v0.8.2 发版提交(`package.json` + `dsh.plugin.json`)。
## 验收
- `pnpm typecheck` / `pnpm test` / `pnpm build`(纯度门)全绿;`tests/locales.spec.ts`
覆盖:无服务回退、attach 后跟随活跃 locale 实时切换、detach 恢复回退、占位符插值、
relativeTime 链路、命名空间名、zh/en 键集相等。
- 手动:设置页切换语言 → 侧边栏全部文案实时切换;`locale.preference: zh` 且浏览器 en
→ 侧边栏中文。
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"id": "dsh-external/dsh-better-sidebar", "id": "dsh-external/dsh-better-sidebar",
"version": "0.8.1", "version": "0.9.0",
"main": "./lib/index.js", "main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器", "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": { "engines": {
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dsh-better-sidebar", "name": "dsh-better-sidebar",
"version": "0.8.1", "version": "0.9.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.", "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.",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -37,6 +37,7 @@
"client": { "client": {
"inject": [ "inject": [
"@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-slots", "@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-ui-conversation" "@deepseek-ai/dsh-client-ui-conversation"
], ],
+1 -1
View File
@@ -122,7 +122,7 @@ export function createBundleRouteHandler(
/** Register the /sidebar/bundle route (disposed with the fiber). */ /** Register the /sidebar/bundle route (disposed with the fiber). */
export function registerBundleRoute(ctx: Context, fence: (req: IncomingMessage) => boolean): () => void { export function registerBundleRoute(ctx: Context, fence: (req: IncomingMessage) => boolean): () => void {
return ctx.httpServer.register({ return ctx.webServer.register({
kind: 'prefix', kind: 'prefix',
path: '/sidebar/bundle', path: '/sidebar/bundle',
handler: createBundleRouteHandler(fence), handler: createBundleRouteHandler(fence),
+127 -53
View File
@@ -29,7 +29,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
import { useSyncExternalStore } from 'react' import { useSyncExternalStore } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts' import type { Context, SidebarSessionList } from '../context-types.ts'
import { appendToDraft } from './conversation-draft.ts'
import { import {
BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeTab, firstLeaf, isAgentTabId, leafWithTab, mapLeaf, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab, BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeTab, firstLeaf, isAgentTabId, leafWithTab, mapLeaf, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
reconcileAgentTerminals, reconcileAgentTerminals,
@@ -44,7 +45,7 @@ import type { TabDragPayload } from './TabBar.tsx'
import { relativeTo } from './paths.ts' import { relativeTo } from './paths.ts'
import { OrphanedTab } from './OrphanedTab.tsx' import { OrphanedTab } from './OrphanedTab.tsx'
import { detectNewDirectSubagent } from './subagent-detect.ts' import { detectNewDirectSubagent } from './subagent-detect.ts'
import { detectNewTask } from './subagent-tasks.ts' import { detectNewJob } from './subagent-jobs.ts'
import { t } from './locales.ts' import { t } from './locales.ts'
import { api, type SessionScope } from './api.ts' import { api, type SessionScope } from './api.ts'
import css from './sidebar.module.css' import css from './sidebar.module.css'
@@ -104,6 +105,15 @@ function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionSco
export function Sidebar(props: { ctx: Context; store: SidebarStore }) { export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const { ctx, store } = props const { ctx, store } = props
// Copy freshness: re-render the whole tree when the DSH locale switches.
// The module-level t() reads the active locale at call time, so a root
// re-render alone re-localizes every panel (no memo barriers below).
const localeRevision = useSyncExternalStore(
useMemo(() => (callback: () => void) => ctx.locale.subscribe(callback), [ctx]),
useCallback(() => ctx.locale.getSnapshot().active, [ctx]),
)
void localeRevision
// Narrow (mobile) viewports collapse the two panels into one: the right // Narrow (mobile) viewports collapse the two panels into one: the right
// panel becomes a full-width drawer holding BOTH workbenches, the bottom // panel becomes a full-width drawer holding BOTH workbenches, the bottom
// panel (and its toggle button) disappears, and the layout push is // panel (and its toggle button) disappears, and the layout push is
@@ -132,6 +142,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const sessionId = snapshot.sessionId const sessionId = snapshot.sessionId
const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
// 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 already squeezes `#root` left, 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])
/** /**
* Bottom-panel merge on narrow viewports: whenever a session is current * Bottom-panel merge on narrow viewports: whenever a session is current
* while narrow (mount, session switch, or a desktop→narrow transition), * while narrow (mount, session switch, or a desktop→narrow transition),
@@ -243,21 +265,21 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}, [sessionList, sessionId, store, ctx]) }, [sessionList, sessionId, store, ctx])
/** /**
* Task auto-activation: the moment a NEW background task appears for the * Job auto-activation: the moment a NEW background job appears for the
* current conversation (a task id the previous snapshot lacked), the * current conversation (a job id the previous snapshot lacked), the
* auto-open pref is on, and the Tasks tab type is enabled, open the panel * auto-open pref is on, and the Jobs tab type is enabled, open the panel
* (if collapsed) and focus the Tasks page. Unlike the subagent trigger * (if collapsed) and focus the Jobs page. Unlike the subagent trigger
* (0 → N only), ANY new task id triggers: the agent may start several * (0 → N only), ANY new job id triggers: the agent may start several
* tasks in one session, and each should surface. A fresh page load never * jobs in one session, and each should surface. A fresh page load never
* triggers — its baseline starts at the current snapshot. * triggers — its baseline starts at the current snapshot.
*/ */
const taskBaselineRef = useRef<SidebarSessionList | undefined>(undefined) const jobBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
useEffect(() => { useEffect(() => {
const prev = taskBaselineRef.current const prev = jobBaselineRef.current
taskBaselineRef.current = sessionList jobBaselineRef.current = sessionList
if (sessionId === undefined || prev === undefined) return if (sessionId === undefined || prev === undefined) return
if (!detectNewTask(prev, sessionList, sessionId)) return if (!detectNewJob(prev, sessionList, sessionId)) return
if (!store.getPrefs().autoOpenTasks) return if (!store.getPrefs().autoOpenJobs) return
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
store.reduce(s => s.panelOpen ? s : togglePanel(s)) store.reduce(s => s.panelOpen ? s : togglePanel(s))
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id })) store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
@@ -285,29 +307,77 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') }) ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
}, [sessionId, store, ctx]) }, [sessionId, store, ctx])
// The app shell's own columns: the bottom panel spans ONLY the center // The app shell's center column: the bottom panel spans ONLY that column
// column ("squeezes the agent output area") — it starts at the app // ("squeezes the agent output area") — it starts at the app sidebar's
// sidebar's right edge and ends at the details column's left edge (the // right edge and ends at the details column's left edge (the details
// details column sits between the center and the right panel). Measured // column sits between the center and the right panel). Measured directly
// from the AppFrame's DOM (the plugin already couples to #root in // from the AppFrame's center column DOM (children[1], the layout.css
// layout.css); a missing frame degrades to 0 (full width). // nth-child(2) column) so the bottom panel tracks the column's real
const [appSidebarWidth, setAppSidebarWidth] = useState(0) // horizontal edges — including the animated margin-right push while the
const [detailsWidth, setDetailsWidth] = useState(0) // right panel opens/closes; a frame that never appears keeps the initial
useEffect(() => { // zero-size fallback (the panel renders at 0 width until measured).
const frame = document.querySelector('#root > div') const [centerRect, setCenterRect] = useState({ left: 0, right: 0 })
const sidebarCol = frame?.children[0] as HTMLElement | undefined // Refs keep the measure step stable across renders and let it skip work
const detailsCol = frame?.children[2] as HTMLElement | undefined // mid-drag: during a width/corner drag the layout push resizes the center
if (sidebarCol === undefined || detailsCol === undefined) return // column every frame, and reacting (setCenterRect → re-render) would
const measure = (): void => { // re-introduce the drag lag this shell deliberately avoids. applyDrag
setAppSidebarWidth(sidebarCol.getBoundingClientRect().width) // writes the bottom panel's edges directly, so measurement pauses then.
setDetailsWidth(detailsCol.getBoundingClientRect().width) const centerColRef = useRef<HTMLElement | null>(null)
} const draggingRef = useRef(false)
measure() const measureCenter = useCallback((): void => {
const observer = new ResizeObserver(measure) if (draggingRef.current) return
observer.observe(sidebarCol) const col = centerColRef.current
observer.observe(detailsCol) if (col === null) return
return () => { observer.disconnect() } const rect = col.getBoundingClientRect()
// The bottom panel only cares about the horizontal edges: a pure height
// change (the bottom panel itself opening/closing) must not re-render,
// so keep the previous object when left/right are unchanged.
setCenterRect(prev =>
prev.left === rect.left && prev.right === rect.right
? prev
: { left: rect.left, right: rect.right })
}, []) }, [])
useEffect(() => {
let disposed = false
let observer: ResizeObserver | undefined
// Locate the AppFrame's center column (children[1] of the frame div —
// layout.css's nth-child(2)). 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 children (the swap mutates them) 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
const frame = document.querySelector('#root > div')
const col = frame?.children[1] as HTMLElement | undefined
if (col === undefined) {
if (centerColRef.current !== null) {
centerColRef.current = null
observer?.disconnect()
observer = undefined
}
return
}
if (centerColRef.current !== col) {
centerColRef.current = col
observer?.disconnect()
observer = new ResizeObserver(measureCenter)
observer.observe(col)
}
measureCenter()
}
locate()
const watcher = new MutationObserver(locate)
const root = document.getElementById('root')
if (root !== null) watcher.observe(root, { childList: true })
return () => {
disposed = true
observer?.disconnect()
watcher.disconnect()
centerColRef.current = null
}
}, [measureCenter])
/** /**
* Bottom-panel first-expansion auto terminal: the FIRST time the user * Bottom-panel first-expansion auto terminal: the FIRST time the user
@@ -357,6 +427,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const [draggingCorner, setDraggingCorner] = useState(false) const [draggingCorner, setDraggingCorner] = useState(false)
const anyDragging = draggingWidth || draggingBottom || draggingCorner 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
// fires to refresh centerRect — this explicit re-measure covers that gap.
useEffect(() => {
draggingRef.current = anyDragging
if (!anyDragging) measureCenter()
}, [anyDragging, measureCenter])
// Clamp mirrors of setWidth/setBottomHeight for mid-drag values (the store // Clamp mirrors of setWidth/setBottomHeight for mid-drag values (the store
// re-clamps on commit; these keep the panels from overshooting mid-drag). // re-clamps on commit; these keep the panels from overshooting mid-drag).
const clampWidth = (width: number): number => const clampWidth = (width: number): number =>
@@ -371,7 +450,11 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const applyDrag = (width: number, height: number): void => { const applyDrag = (width: number, height: number): void => {
panelRef.current?.style.setProperty('width', `${width}px`) panelRef.current?.style.setProperty('width', `${width}px`)
bottomRef.current?.style.setProperty('height', `${height}px`) bottomRef.current?.style.setProperty('height', `${height}px`)
bottomRef.current?.style.setProperty('right', `${width + detailsWidth}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 - centerRect.right) + (width - (state?.width ?? 0))}px`)
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`) document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`) document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
if (cornerRef.current !== null) { if (cornerRef.current !== null) {
@@ -493,18 +576,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
*/ */
const referenceInChat = useCallback((path: string): void => { const referenceInChat = useCallback((path: string): void => {
if (sessionId === undefined) return if (sessionId === undefined) return
try { appendToDraft(ctx, sessionId, `@${relativeTo(cwd ?? '', path)}`)
const actx = ctx.sessions.scope(sessionId)
if (actx === undefined) return
const conversation = ctx.get('conversation') as SidebarConversation | undefined
if (conversation === undefined) return
const input = conversation.input.for(actx)
const mention = `@${relativeTo(cwd ?? '', path)}`
const draft = input.state.getSnapshot().draft
input.setDraft(draft.trim() === '' ? mention : `${draft} ${mention}`)
} catch (error) {
console.warn('[dsh-better-sidebar] reference insert failed:', error)
}
}, [ctx, sessionId, cwd]) }, [ctx, sessionId, cwd])
if (state === undefined || sessionId === undefined) { if (state === undefined || sessionId === undefined) {
@@ -675,11 +747,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)} className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)}
style={{ style={{
height: Math.min(state.bottomHeight, window.innerHeight), height: Math.min(state.bottomHeight, window.innerHeight),
left: appSidebarWidth, left: centerRect.left,
// Ends at the details column's left edge (the details column sits // Direct from the center column's measured right edge: the bottom
// between the center and the right panel) plus the right panel's // panel spans ONLY the center column, ending exactly at the
// own width — the bottom panel squeezes ONLY the center column. // details column's left edge (the details column sits between the
right: detailsWidth + (state.panelOpen ? Math.min(state.width, window.innerWidth) : 0), // center and the right panel, and the right panel's margin-right
// push is already baked into centerRect.right).
right: window.innerWidth - centerRect.right,
// The seam against the open right panel needs its own hairline // The seam against the open right panel needs its own hairline
// (the right panel's border-left alone is covered by this panel's // (the right panel's border-left alone is covered by this panel's
// fill — without it the corner looks cut off). // fill — without it the corner looks cut off).
+37 -37
View File
@@ -274,15 +274,15 @@
color: var(--dsw-alias-label-primary); color: var(--dsw-alias-label-primary);
} }
/* ── Background-task section (below the topology tree) ────────────────── */ /* ── Background-job section (below the topology tree) ────────────────── */
.tasks { .jobs {
margin-top: 10px; margin-top: 10px;
border-top: 1px solid var(--dsw-alias-border-l2); border-top: 1px solid var(--dsw-alias-border-l2);
padding-top: 8px; padding-top: 8px;
} }
.tasksHeader { .jobsHeader {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
@@ -290,7 +290,7 @@
padding: 0 2px; padding: 0 2px;
} }
.tasksTitle { .jobsTitle {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
font: var(--dsw-font-xxxs-strong-11); font: var(--dsw-font-xxxs-strong-11);
@@ -300,13 +300,13 @@
white-space: nowrap; white-space: nowrap;
} }
.tasksCount { .jobsCount {
flex: none; flex: none;
font: var(--dsw-font-xxxs-11); font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
} }
.tasksList { .jobsList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
@@ -315,30 +315,30 @@
list-style: none; list-style: none;
} }
/* One task row; the whole row lights up on hover, the expandable target /* One job row; the whole row lights up on hover, the expandable target
stays transparent (the kill button rides on the row's right edge). */ stays transparent (the kill button rides on the row's right edge). */
.tasksRow { .jobsRow {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
border-radius: 8px; border-radius: 8px;
} }
.tasksRow:hover { .jobsRow:hover {
background: var(--dsw-alias-interactive-bg-hover); background: var(--dsw-alias-interactive-bg-hover);
} }
.tasksRowSettled { .jobsRowSettled {
opacity: 0.8; opacity: 0.8;
} }
/* The row whose output is shown in the bottom dock. */ /* The row whose output is shown in the bottom dock. */
.tasksRowSelected, .jobsRowSelected,
.tasksRowSelected:hover { .jobsRowSelected:hover {
background: var(--dsw-alias-interactive-bg-active); background: var(--dsw-alias-interactive-bg-active);
} }
.tasksRowMain { .jobsRowMain {
flex: 1; flex: 1;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -355,15 +355,15 @@
outline: none; outline: none;
} }
.tasksRowMain:focus-visible { .jobsRowMain:focus-visible {
background: var(--dsw-alias-interactive-bg-hover); background: var(--dsw-alias-interactive-bg-hover);
} }
.tasksDot { .jobsDot {
margin-top: 5px; margin-top: 5px;
} }
.tasksContent { .jobsContent {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
@@ -372,14 +372,14 @@
/* The command line: a kind badge followed by the command itself in the code /* The command line: a kind badge followed by the command itself in the code
face, like the terminal's live lines. */ face, like the terminal's live lines. */
.tasksLabelLine { .jobsLabelLine {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
min-width: 0; min-width: 0;
} }
.tasksKind { .jobsKind {
flex: none; flex: none;
max-width: 90px; max-width: 90px;
overflow: hidden; overflow: hidden;
@@ -393,7 +393,7 @@
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
} }
.tasksLabel { .jobsLabel {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@@ -405,7 +405,7 @@
color: var(--dsw-alias-label-primary); color: var(--dsw-alias-label-primary);
} }
.tasksSecondary { .jobsSecondary {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -413,7 +413,7 @@
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
} }
.tasksKill { .jobsKill {
flex: none; flex: none;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -428,14 +428,14 @@
cursor: pointer; cursor: pointer;
} }
.tasksKill:hover { .jobsKill:hover {
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
color: var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary);
} }
/* The armed (first-click) state: a filled confirm chip instead of the icon. */ /* The armed (first-click) state: a filled confirm chip instead of the icon. */
.tasksKillArmed, .jobsKillArmed,
.tasksKillArmed:hover { .jobsKillArmed:hover {
width: auto; width: auto;
height: 20px; height: 20px;
padding: 0 8px; padding: 0 8px;
@@ -445,12 +445,12 @@
white-space: nowrap; white-space: nowrap;
} }
.tasksKill:disabled { .jobsKill:disabled {
opacity: 0.5; opacity: 0.5;
cursor: default; cursor: default;
} }
.tasksKillError { .jobsKillError {
flex: none; flex: none;
margin-right: 4px; margin-right: 4px;
font: var(--dsw-font-xxxs-11); font: var(--dsw-font-xxxs-11);
@@ -460,9 +460,9 @@
/* ── The shared output dock (bottom of the page, sticky like a terminal) ── */ /* ── The shared output dock (bottom of the page, sticky like a terminal) ── */
/* Direct child of the scrolling body, so it docks at the scrollport's bottom /* Direct child of the scrolling body, so it docks at the scrollport's bottom
edge while the task list scrolls above it; a solid background keeps the edge while the job list scrolls above it; a solid background keeps the
tree from showing through. */ tree from showing through. */
.tasksPane { .jobsPane {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
z-index: 1; z-index: 1;
@@ -474,7 +474,7 @@
overflow: hidden; overflow: hidden;
} }
.tasksPaneHeader { .jobsPaneHeader {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
@@ -483,11 +483,11 @@
border-bottom: 1px solid var(--dsw-alias-border-l1); border-bottom: 1px solid var(--dsw-alias-border-l1);
} }
.tasksPaneDot { .jobsPaneDot {
flex: none; flex: none;
} }
.tasksPaneLabel { .jobsPaneLabel {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@@ -499,13 +499,13 @@
color: var(--dsw-alias-label-primary); color: var(--dsw-alias-label-primary);
} }
.tasksPaneStatus { .jobsPaneStatus {
flex: none; flex: none;
font: var(--dsw-font-xxxs-11); font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
} }
.tasksPaneClose { .jobsPaneClose {
flex: none; flex: none;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -519,11 +519,11 @@
cursor: pointer; cursor: pointer;
} }
.tasksPaneClose:hover { .jobsPaneClose:hover {
background: var(--dsw-alias-interactive-bg-hover); background: var(--dsw-alias-interactive-bg-hover);
} }
.tasksPanePre { .jobsPanePre {
margin: 0; margin: 0;
max-height: 200px; max-height: 200px;
overflow: auto; overflow: auto;
@@ -536,12 +536,12 @@
word-break: break-word; word-break: break-word;
} }
.tasksPaneHint { .jobsPaneHint {
padding: 8px 10px; padding: 8px 10px;
font: var(--dsw-font-xxxs-11); font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
} }
.tasksPaneError { .jobsPaneError {
color: var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary);
} }
+103 -103
View File
@@ -33,7 +33,7 @@ import type {
SidebarSubagentCatalog, SidebarSubagentCatalog,
SidebarSubagentChildEntry, SidebarSubagentChildEntry,
SidebarSubagentDiagnosticEntry, SidebarSubagentDiagnosticEntry,
SidebarTaskView, SidebarJobView,
} from '../context-types.ts' } from '../context-types.ts'
import { import {
collectBranchIds, collectBranchIds,
@@ -42,15 +42,15 @@ import {
} from './subagent-detect.ts' } from './subagent-detect.ts'
import { lastActivity } from './subagent-activity.ts' import { lastActivity } from './subagent-activity.ts'
import { import {
collectTreeTasks, collectTreeJobs,
formatTaskDuration, formatJobDuration,
isTaskLive, isJobLive,
orderTasks, orderJobs,
taskDotState, jobDotState,
taskStatusLabel, jobStatusLabel,
type TreeTask, type TreeJob,
} from './subagent-tasks.ts' } from './subagent-jobs.ts'
import { api, type TaskOutputResult } from './api.ts' import { api, type JobOutputResult } from './api.ts'
import { IconStopOutline16 } from './icons.tsx' import { IconStopOutline16 } from './icons.tsx'
import { t } from './locales.ts' import { t } from './locales.ts'
import css from './SubagentView.module.css' import css from './SubagentView.module.css'
@@ -59,10 +59,10 @@ import css from './SubagentView.module.css'
const POLL_MS = 3000 const POLL_MS = 3000
/** Preview cap of one tool-call argument line. */ /** Preview cap of one tool-call argument line. */
const ARGS_PREVIEW = 60 const ARGS_PREVIEW = 60
/** Refresh cadence of an expanded task-output panel while its task runs. */ /** Refresh cadence of an expanded job-output panel while its job runs. */
const TASK_POLL_MS = 2000 const JOB_POLL_MS = 2000
/** How long the kill button stays armed before it needs re-confirming. */ /** How long the kill button stays armed before it needs re-confirming. */
const TASK_KILL_ARM_MS = 3000 const JOB_KILL_ARM_MS = 3000
/** The direct subagent children of one parent (durable `origin` rows). */ /** The direct subagent children of one parent (durable `origin` rows). */
function directChildren( function directChildren(
@@ -358,24 +358,24 @@ function CatalogRows({
} }
/** /**
* The shared output dock of the tasks section: ONE pane at the bottom of the * The shared output dock of the jobs section: ONE pane at the bottom of the
* sidebar body (sticky, terminal-like) shows the SELECTED task's output as * sidebar body (sticky, terminal-like) shows the SELECTED job's output as
* the MODEL has read it so far (replayed from the owner session's event * the MODEL has read it so far (replayed from the owner session's event
* log), refreshed every {@link TASK_POLL_MS} while the task runs and the * log), refreshed every {@link JOB_POLL_MS} while the job runs and the
* page is visible. The model's `task_output` cursor is never touched — the * page is visible. The model's `job_output` cursor is never touched — the
* pane can never steal the agent's bytes, and it stays empty until the * pane can never steal the agent's bytes, and it stays empty until the
* agent reads the task. A single dock — not a panel per row — keeps the * agent reads the job. A single dock — not a panel per row — keeps the
* task list compact and stable when many tasks are running. * job list compact and stable when many jobs are running.
*/ */
function TaskOutputPane(props: { function JobOutputPane(props: {
ownerSessionId: string ownerSessionId: string
task: SidebarTaskView job: SidebarJobView
/** The page is visible (active tab + open panel): skip polling otherwise. */ /** The page is visible (active tab + open panel): skip polling otherwise. */
active: boolean active: boolean
onClose: () => void onClose: () => void
}) { }) {
const { ownerSessionId, task, active, onClose } = props const { ownerSessionId, job, active, onClose } = props
const [state, setState] = useState<'loading' | TaskOutputResult | 'error'>('loading') const [state, setState] = useState<'loading' | JobOutputResult | 'error'>('loading')
const controllerRef = useRef<AbortController | undefined>(undefined) const controllerRef = useRef<AbortController | undefined>(undefined)
const preRef = useRef<HTMLPreElement>(null) const preRef = useRef<HTMLPreElement>(null)
@@ -384,44 +384,44 @@ function TaskOutputPane(props: {
const controller = new AbortController() const controller = new AbortController()
controllerRef.current = controller controllerRef.current = controller
try { try {
const result = await api.taskOutput({ sessionId: ownerSessionId }, task.id, controller.signal) const result = await api.jobOutput({ sessionId: ownerSessionId }, job.id, controller.signal)
setState(result) setState(result)
} catch { } catch {
// A newer pull aborted this one, or the wire failed: keep the last // A newer pull aborted this one, or the wire failed: keep the last
// known output; only a dock that never loaded anything shows an error. // known output; only a dock that never loaded anything shows an error.
setState(current => (current === 'loading' ? 'error' : current)) setState(current => (current === 'loading' ? 'error' : current))
} }
}, [ownerSessionId, task.id]) }, [ownerSessionId, job.id])
useEffect(() => { useEffect(() => {
void load() void load()
if (!active || !isTaskLive(task)) return if (!active || !isJobLive(job)) return
const timer = window.setInterval(() => { void load() }, TASK_POLL_MS) const timer = window.setInterval(() => { void load() }, JOB_POLL_MS)
return () => { window.clearInterval(timer) } return () => { window.clearInterval(timer) }
}, [load, active, task.status]) }, [load, active, job.status])
useEffect(() => () => { controllerRef.current?.abort() }, []) useEffect(() => () => { controllerRef.current?.abort() }, [])
// Terminal-tail behavior: while the task runs, each refresh pins the view // Terminal-tail behavior: while the job runs, each refresh pins the view
// to the newest output; a settled dock leaves scrolling to the reader. // to the newest output; a settled dock leaves scrolling to the reader.
useEffect(() => { useEffect(() => {
if (!isTaskLive(task) || typeof state !== 'object' || state.text.length === 0) return if (!isJobLive(job) || typeof state !== 'object' || state.text.length === 0) return
const pre = preRef.current const pre = preRef.current
if (pre !== null) pre.scrollTop = pre.scrollHeight if (pre !== null) pre.scrollTop = pre.scrollHeight
}, [state, task.status]) }, [state, job.status])
return ( return (
<div className={css.tasksPane} role="region" aria-label={`${task.label} ${t('tasks')}`}> <div className={css.jobsPane} role="region" aria-label={`${job.label} ${t('jobs')}`}>
<div className={css.tasksPaneHeader}> <div className={css.jobsPaneHeader}>
<StateDot state={taskDotState(task.status)} className={css.tasksPaneDot} /> <StateDot state={jobDotState(job.status)} className={css.jobsPaneDot} />
<span className={css.tasksPaneLabel} title={task.label}>{task.label}</span> <span className={css.jobsPaneLabel} title={job.label}>{job.label}</span>
<span className={css.tasksPaneStatus}> <span className={css.jobsPaneStatus}>
{taskStatusLabel(task.status, t)} {jobStatusLabel(job.status, t)}
{task.detail !== undefined && task.detail !== '' ? ` · ${task.detail}` : ''} {job.detail !== undefined && job.detail !== '' ? ` · ${job.detail}` : ''}
</span> </span>
<button <button
type="button" type="button"
className={css.tasksPaneClose} className={css.jobsPaneClose}
aria-label={t('close')} aria-label={t('close')}
title={t('close')} title={t('close')}
onClick={onClose} onClick={onClose}
@@ -429,18 +429,18 @@ function TaskOutputPane(props: {
<IconStopOutline16 size={10} /> <IconStopOutline16 size={10} />
</button> </button>
</div> </div>
{state === 'loading' && <div className={css.tasksPaneHint}>{t('loading')}</div>} {state === 'loading' && <div className={css.jobsPaneHint}>{t('loading')}</div>}
{state === 'error' && ( {state === 'error' && (
<div className={`${css.tasksPaneHint} ${css.tasksPaneError}`}>{t('taskOutputError')}</div> <div className={`${css.jobsPaneHint} ${css.jobsPaneError}`}>{t('jobOutputError')}</div>
)} )}
{typeof state === 'object' && ( {typeof state === 'object' && (
<> <>
{state.text.length > 0 {state.text.length > 0
? <pre ref={preRef} className={css.tasksPanePre}>{state.text}</pre> ? <pre ref={preRef} className={css.jobsPanePre}>{state.text}</pre>
: state.read : state.read
? <div className={css.tasksPaneHint}>{t('taskNoOutput')}</div> ? <div className={css.jobsPaneHint}>{t('jobNoOutput')}</div>
: <div className={css.tasksPaneHint}>{t('taskNotReadYet')}</div>} : <div className={css.jobsPaneHint}>{t('jobNotReadYet')}</div>}
{state.truncated && <div className={css.tasksPaneHint}>{t('taskOutputTruncated')}</div>} {state.truncated && <div className={css.jobsPaneHint}>{t('jobOutputTruncated')}</div>}
</> </>
)} )}
</div> </div>
@@ -448,24 +448,24 @@ function TaskOutputPane(props: {
} }
/** /**
* The background-task section of the Subagent page: every task of the whole * The background-job section of the Subagent page: every job of the whole
* current tree (main agent + subagents, owner-labeled), fed by the harness * current tree (main agent + subagents, owner-labeled), fed by the harness
* `session/tasks` push mirror. Clicking a row feeds its model-read output to * `session/jobs` push mirror. Clicking a row feeds its model-read output to
* the shared bottom dock (event replay — never the model's cursor); live * the shared bottom dock (event replay — never the model's cursor); live
* rows carry a two-click-confirm kill button. Renders nothing while the * rows carry a two-click-confirm kill button. Renders nothing while the
* tree has no tasks. * tree has no jobs.
*/ */
function TasksSection(props: { function JobsSection(props: {
byId: SidebarSessionList['byId'] byId: SidebarSessionList['byId']
tasksBySession: SidebarSessionList['tasksBySession'] jobsBySession: SidebarSessionList['jobsBySession']
rootId: string | undefined rootId: string | undefined
/** The page is visible (active tab + open panel): skip polling otherwise. */ /** The page is visible (active tab + open panel): skip polling otherwise. */
active: boolean active: boolean
}) { }) {
const { byId, tasksBySession, rootId, active } = props const { byId, jobsBySession, rootId, active } = props
const rows = useMemo( const rows = useMemo(
() => orderTasks(collectTreeTasks(byId, tasksBySession, rootId)), () => orderJobs(collectTreeJobs(byId, jobsBySession, rootId)),
[byId, tasksBySession, rootId], [byId, jobsBySession, rootId],
) )
const [selectedId, setSelectedId] = useState<string | undefined>(undefined) const [selectedId, setSelectedId] = useState<string | undefined>(undefined)
const [armedId, setArmedId] = useState<string | undefined>(undefined) const [armedId, setArmedId] = useState<string | undefined>(undefined)
@@ -475,12 +475,12 @@ function TasksSection(props: {
const [now, setNow] = useState(() => Date.now()) const [now, setNow] = useState(() => Date.now())
const selectedRow = useMemo( const selectedRow = useMemo(
() => (selectedId === undefined ? undefined : rows.find(row => row.task.id === selectedId)), () => (selectedId === undefined ? undefined : rows.find(row => row.job.id === selectedId)),
[rows, selectedId], [rows, selectedId],
) )
const liveCount = useMemo( const liveCount = useMemo(
() => rows.reduce((count, row) => count + (isTaskLive(row.task) ? 1 : 0), 0), () => rows.reduce((count, row) => count + (isJobLive(row.job) ? 1 : 0), 0),
[rows], [rows],
) )
const multiOwner = useMemo( const multiOwner = useMemo(
@@ -491,7 +491,7 @@ function TasksSection(props: {
// The kill button stays armed only briefly; a stray click must never kill. // The kill button stays armed only briefly; a stray click must never kill.
useEffect(() => { useEffect(() => {
if (armedId === undefined) return if (armedId === undefined) return
const timer = window.setTimeout(() => { setArmedId(undefined) }, TASK_KILL_ARM_MS) const timer = window.setTimeout(() => { setArmedId(undefined) }, JOB_KILL_ARM_MS)
return () => { window.clearTimeout(timer) } return () => { window.clearTimeout(timer) }
}, [armedId]) }, [armedId])
@@ -502,7 +502,7 @@ function TasksSection(props: {
return () => { window.clearInterval(timer) } return () => { window.clearInterval(timer) }
}, [liveCount]) }, [liveCount])
// The docked output pane follows its task: when the selected task leaves // The docked output pane follows its job: when the selected job leaves
// the mirror (settled and dropped, or the tree switched), close the dock. // the mirror (settled and dropped, or the tree switched), close the dock.
useEffect(() => { useEffect(() => {
if (selectedId !== undefined && selectedRow === undefined) setSelectedId(undefined) if (selectedId !== undefined && selectedRow === undefined) setSelectedId(undefined)
@@ -511,13 +511,13 @@ function TasksSection(props: {
// NOTE: every hook must live ABOVE the empty-state return — a hook below it // NOTE: every hook must live ABOVE the empty-state return — a hook below it
// would flip this component's hook count when the mirror empties and crash // would flip this component's hook count when the mirror empties and crash
// React with "Rendered fewer hooks than expected" (the #300 regression). // React with "Rendered fewer hooks than expected" (the #300 regression).
const kill = useCallback(async (row: TreeTask): Promise<void> => { const kill = useCallback(async (row: TreeJob): Promise<void> => {
setKillingId(row.task.id) setKillingId(row.job.id)
setKillErrorId(undefined) setKillErrorId(undefined)
try { try {
await api.taskKill({ sessionId: row.ownerSessionId }, row.task.id) await api.jobKill({ sessionId: row.ownerSessionId }, row.job.id)
} catch { } catch {
setKillErrorId(row.task.id) setKillErrorId(row.job.id)
} finally { } finally {
setKillingId(undefined) setKillingId(undefined)
setArmedId(undefined) setArmedId(undefined)
@@ -527,84 +527,84 @@ function TasksSection(props: {
if (rows.length === 0) return null if (rows.length === 0) return null
const countLabel = liveCount > 0 const countLabel = liveCount > 0
? t('tasksCountRunning', { count: rows.length, running: liveCount }) ? t('jobsCountRunning', { count: rows.length, running: liveCount })
: t('tasksCount', { count: rows.length }) : t('jobsCount', { count: rows.length })
return ( return (
<> <>
<section className={css.tasks} aria-label={t('tasks')}> <section className={css.jobs} aria-label={t('jobs')}>
<div className={css.tasksHeader}> <div className={css.jobsHeader}>
<span className={css.tasksTitle}>{t('tasks')}</span> <span className={css.jobsTitle}>{t('jobs')}</span>
<span className={css.tasksCount}>{countLabel}</span> <span className={css.jobsCount}>{countLabel}</span>
</div> </div>
<ul className={css.tasksList} aria-label={t('tasks')}> <ul className={css.jobsList} aria-label={t('jobs')}>
{rows.map((row) => { {rows.map((row) => {
const { task } = row const { job } = row
const live = isTaskLive(task) const live = isJobLive(job)
const selected = selectedId === task.id const selected = selectedId === job.id
const armed = armedId === task.id const armed = armedId === job.id
const killing = killingId === task.id const killing = killingId === job.id
const killFailed = killErrorId === task.id const killFailed = killErrorId === job.id
const elapsed = live const elapsed = live
? now - task.startedAt ? now - job.startedAt
: (task.finishedAt ?? task.startedAt) - task.startedAt : (job.finishedAt ?? job.startedAt) - job.startedAt
const secondary = [ const secondary = [
...(multiOwner ? [row.ownerTitle] : []), ...(multiOwner ? [row.ownerTitle] : []),
taskStatusLabel(task.status, t), jobStatusLabel(job.status, t),
...(task.detail !== undefined && task.detail !== '' ? [task.detail] : []), ...(job.detail !== undefined && job.detail !== '' ? [job.detail] : []),
formatTaskDuration(elapsed, t), formatJobDuration(elapsed, t),
].filter(Boolean).join(' · ') ].filter(Boolean).join(' · ')
return ( return (
<li <li
key={task.id} key={job.id}
className={clsx( className={clsx(
css.tasksRow, css.jobsRow,
!live && css.tasksRowSettled, !live && css.jobsRowSettled,
selected && css.tasksRowSelected, selected && css.jobsRowSelected,
)} )}
> >
<button <button
type="button" type="button"
className={css.tasksRowMain} className={css.jobsRowMain}
aria-pressed={selected} aria-pressed={selected}
aria-label={`${task.label} ${secondary}`} aria-label={`${job.label} ${secondary}`}
onClick={() => { setSelectedId(selected ? undefined : task.id) }} onClick={() => { setSelectedId(selected ? undefined : job.id) }}
> >
<StateDot state={taskDotState(task.status)} className={css.tasksDot} /> <StateDot state={jobDotState(job.status)} className={css.jobsDot} />
<span className={css.tasksContent}> <span className={css.jobsContent}>
<span className={css.tasksLabelLine}> <span className={css.jobsLabelLine}>
<span className={css.tasksKind}>{task.kind}</span> <span className={css.jobsKind}>{job.kind}</span>
<span className={css.tasksLabel} title={task.label}>{task.label}</span> <span className={css.jobsLabel} title={job.label}>{job.label}</span>
</span> </span>
<span className={css.tasksSecondary}>{secondary}</span> <span className={css.jobsSecondary}>{secondary}</span>
</span> </span>
</button> </button>
{task.status === 'running' && ( {job.status === 'running' && (
<button <button
type="button" type="button"
className={armed ? `${css.tasksKill} ${css.tasksKillArmed}` : css.tasksKill} className={armed ? `${css.jobsKill} ${css.jobsKillArmed}` : css.jobsKill}
aria-label={armed ? t('taskKillConfirm') : t('taskKill')} aria-label={armed ? t('jobKillConfirm') : t('jobKill')}
title={armed ? t('taskKillConfirm') : t('taskKill')} title={armed ? t('jobKillConfirm') : t('jobKill')}
disabled={killing} disabled={killing}
onClick={(event) => { onClick={(event) => {
event.stopPropagation() event.stopPropagation()
if (armed) void kill(row) if (armed) void kill(row)
else setArmedId(task.id) else setArmedId(job.id)
}} }}
> >
{armed ? t('taskKillConfirm') : <IconStopOutline16 size={12} />} {armed ? t('jobKillConfirm') : <IconStopOutline16 size={12} />}
</button> </button>
)} )}
{killFailed && <span className={css.tasksKillError}>{t('taskKillError')}</span>} {killFailed && <span className={css.jobsKillError}>{t('jobKillError')}</span>}
</li> </li>
) )
})} })}
</ul> </ul>
</section> </section>
{selectedRow !== undefined && ( {selectedRow !== undefined && (
<TaskOutputPane <JobOutputPane
ownerSessionId={selectedRow.ownerSessionId} ownerSessionId={selectedRow.ownerSessionId}
task={selectedRow.task} job={selectedRow.job}
active={active} active={active}
onClose={() => { setSelectedId(undefined) }} onClose={() => { setSelectedId(undefined) }}
/> />
@@ -854,9 +854,9 @@ export function SubagentView(props: {
</div> </div>
)} )}
</div> </div>
<TasksSection <JobsSection
byId={byId} byId={byId}
tasksBySession={list.tasksBySession} jobsBySession={list.jobsBySession}
rootId={rootId} rootId={rootId}
active={active} active={active}
/> />
+141 -3
View File
@@ -10,6 +10,7 @@
* row below the host's title bar, VSCode-style. * row below the host's title bar, VSCode-style.
*/ */
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx' import clsx from 'clsx'
import { EditorState } from '@codemirror/state' import { EditorState } from '@codemirror/state'
import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view' import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
@@ -20,6 +21,8 @@ import { languageForPath } from './lang.ts'
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts' import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
import { isDarkScheme, subscribeColorScheme } from './theme.ts' import { isDarkScheme, subscribeColorScheme } from './theme.ts'
import { SandboxStatusBar } from './SandboxStatusBar.tsx' import { SandboxStatusBar } from './SandboxStatusBar.tsx'
import { appendToDraft } from './conversation-draft.ts'
import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts'
import { t } from './locales.ts' import { t } from './locales.ts'
import type { FileViewerProps } from './service.ts' import type { FileViewerProps } from './service.ts'
import css from './sidebar.module.css' import css from './sidebar.module.css'
@@ -27,6 +30,13 @@ import css from './sidebar.module.css'
/** Previewable files (rendered output vs source editing). */ /** Previewable files (rendered output vs source editing). */
type ViewMode = 'preview' | 'edit' type ViewMode = 'preview' | 'edit'
/** The floating "add to conversation" action: payload + viewport anchor. */
interface SelectionPopup {
insert: string
left: number
top: number
}
/** /**
* The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the * The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the
* preview must stay in an opaque origin — with the route's own origin it * preview must stay in an opaque origin — with the route's own origin it
@@ -37,7 +47,7 @@ type ViewMode = 'preview' | 'edit'
export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals' export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
export function TextEditor(props: FileViewerProps) { export function TextEditor(props: FileViewerProps) {
const { scope, path, viewerId, content, truncated } = props const { ctx, scope, path, viewerId, content, truncated } = props
const [mode, setMode] = useState<ViewMode>('preview') const [mode, setMode] = useState<ViewMode>('preview')
/** The editor's current text (null while clean); preview renders this. */ /** The editor's current text (null while clean); preview renders this. */
const [draft, setDraft] = useState<string | null>(null) const [draft, setDraft] = useState<string | null>(null)
@@ -50,6 +60,36 @@ export function TextEditor(props: FileViewerProps) {
const themeCompRef = useRef<CmThemeCompartment | null>(null) const themeCompRef = useRef<CmThemeCompartment | null>(null)
/** The app's resolved color scheme; the editor re-themes in place on flips. */ /** The app's resolved color scheme; the editor re-themes in place on flips. */
const [dark, setDark] = useState(() => isDarkScheme()) const [dark, setDark] = useState(() => isDarkScheme())
/** The floating "add to conversation" popup (viewport-anchored; null = hidden). */
const [popup, setPopup] = useState<SelectionPopup | null>(null)
/** Live mirror of the popup state for click-time reads (no re-render race). */
const popupRef = useRef<SelectionPopup | null>(null)
/** The markdown preview container (selection-containment + line lookup). */
const mdRef = useRef<HTMLDivElement>(null)
const hidePopup = (): void => {
popupRef.current = null
setPopup(null)
}
/** Anchor the popup above the selection center; clamp inside the viewport. */
const showPopup = (insert: string, left: number, top: number): void => {
const next: SelectionPopup = {
insert,
left: Math.min(Math.max(left, 80), window.innerWidth - 80),
top,
}
popupRef.current = next
setPopup(next)
}
/** The popup button's click: insert the stored payload into the draft. */
const commitPopup = (): void => {
const current = popupRef.current
if (current === null) return
appendToDraft(ctx, scope.sessionId, current.insert)
hidePopup()
}
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), []) useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
@@ -59,6 +99,7 @@ export function TextEditor(props: FileViewerProps) {
setDraft(null) setDraft(null)
setDirty(false) setDirty(false)
setSaveState('idle') setSaveState('idle')
hidePopup()
}, [content]) }, [content])
// Create the CodeMirror editor once the content is loaded. The view owns // Create the CodeMirror editor once the content is loaded. The view owns
@@ -100,6 +141,49 @@ export function TextEditor(props: FileViewerProps) {
...defaultKeymap, ...defaultKeymap,
...historyKeymap, ...historyKeymap,
]), ]),
// Selection popup (the catch-all code viewer only): a non-empty
// selection anchors the floating "add to conversation" button above
// its head. Scrolling (geometry/viewport change) or losing focus
// hides it; typing collapses the selection and hides it too.
...(viewerId === 'code' ? [
CodeMirrorView.updateListener.of((update) => {
if (update.geometryChanged || update.viewportChanged) {
hidePopup()
return
}
if (!update.view.hasFocus) {
hidePopup()
return
}
if (!(update.selectionSet || update.docChanged || update.focusChanged)) return
const sel = update.state.selection.main
if (sel.empty) {
hidePopup()
return
}
const text = update.state.sliceDoc(sel.from, sel.to)
if (text.trim() === '') {
hidePopup()
return
}
// Page coordinates (the document root may scroll); the popup is
// position:fixed, so convert to viewport coordinates.
const rect = update.view.coordsAtPos(sel.head)
if (rect === null) {
hidePopup()
return
}
const doc = update.state.doc
showPopup(
buildSelectionInsert(path, scope.cwd, {
start: doc.lineAt(sel.from).number,
end: doc.lineAt(sel.to).number,
}, text),
rect.left - window.scrollX + (rect.right - rect.left) / 2,
rect.top - window.scrollY,
)
}),
] : []),
], ],
}) })
const view = new CodeMirrorView({ state, parent: host }) const view = new CodeMirrorView({ state, parent: host })
@@ -124,8 +208,10 @@ export function TextEditor(props: FileViewerProps) {
}, [dark]) }, [dark])
// The editor may have been display:none while previewing; re-measure when // The editor may have been display:none while previewing; re-measure when
// it becomes visible again (CodeMirror sizes itself on reveal). // it becomes visible again (CodeMirror sizes itself on reveal). A mode
// flip also invalidates any anchored selection popup.
useEffect(() => { useEffect(() => {
hidePopup()
if (mode === 'edit') viewRef.current?.requestMeasure() if (mode === 'edit') viewRef.current?.requestMeasure()
}, [mode]) }, [mode])
@@ -147,6 +233,39 @@ export function TextEditor(props: FileViewerProps) {
const markdown = viewerId === 'markdown' const markdown = viewerId === 'markdown'
const html = viewerId === 'html' const html = viewerId === 'html'
/**
* Selection popup for the markdown preview: a mouse-up inside the preview
* container anchors the floating "add to conversation" button above the
* selection. Line numbers come from a best-effort reverse-search of the
* selected text in the source ({@link linesOfSelection} — an ambiguous or
* missing hit omits them). The button's own mousedown preventDefaults so
* the selection survives until the click commits.
*/
const handlePreviewMouseUp = (): void => {
const sel = window.getSelection()
if (sel === null || sel.isCollapsed || sel.anchorNode === null || sel.focusNode === null) {
hidePopup()
return
}
const host = mdRef.current
if (host === null || !host.contains(sel.anchorNode) || !host.contains(sel.focusNode)) {
hidePopup()
return
}
const text = sel.toString()
if (text.trim() === '') {
hidePopup()
return
}
const rect = sel.getRangeAt(0).getBoundingClientRect()
const lines = linesOfSelection(draft ?? content ?? '', text)
showPopup(
buildSelectionInsert(path, scope.cwd, lines ?? undefined, text),
rect.left + rect.width / 2,
rect.top,
)
}
const editable = content !== undefined const editable = content !== undefined
const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : '' const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
// Per-feature sandbox escape hatch: the global side card setting (warned) // Per-feature sandbox escape hatch: the global side card setting (warned)
@@ -204,7 +323,12 @@ export function TextEditor(props: FileViewerProps) {
</> </>
)} )}
{markdown && mode === 'preview' && ( {markdown && mode === 'preview' && (
<div className={css.editorMd}> <div
className={css.editorMd}
ref={mdRef}
onMouseUp={handlePreviewMouseUp}
onScroll={hidePopup}
>
<MarkdownText text={draft ?? content ?? ''} /> <MarkdownText text={draft ?? content ?? ''} />
</div> </div>
)} )}
@@ -231,6 +355,20 @@ export function TextEditor(props: FileViewerProps) {
/> />
</> </>
)} )}
{popup !== null && createPortal(
<button
type="button"
className={css.selectionPopup}
style={{ left: popup.left, top: popup.top }}
// Keep the selection (and CodeMirror focus) alive until the click
// commits — without this the popup unmounts before click lands.
onMouseDown={(event) => { event.preventDefault() }}
onClick={commitPopup}
>
{t('addToConversation')}
</button>,
document.body,
)}
</> </>
) )
} }
+14 -14
View File
@@ -61,17 +61,17 @@ export interface FsTextResult { kind: 'text'; content: string; truncated: boolea
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string } export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
/** /**
* One tasks.output response: the output the MODEL has read so far for the * One jobs.output response: the output the MODEL has read so far for the
* task (replayed from the owner session's event log — the model's * job (replayed from the owner session's event log — the model's
* task_output cursor is never touched, so the pane can never steal the * job_output cursor is never touched, so the pane can never steal the
* agent's bytes). `read` is false until the model actually called * agent's bytes). `read` is false until the model actually called
* task_output for the task. * job_output for the job.
*/ */
export interface TaskOutputResult { export interface JobOutputResult {
text: string text: string
/** True when the host capped the text at its output limit. */ /** True when the host capped the text at its output limit. */
truncated: boolean truncated: boolean
/** Whether the model has read the task at least once. */ /** Whether the model has read the job at least once. */
read: boolean read: boolean
} }
@@ -161,15 +161,15 @@ export const api = {
agentPtyClose: (uuid: string) => agentPtyClose: (uuid: string) =>
call<{ ok: true }>('agent-pty.close', { uuid }), call<{ ok: true }>('agent-pty.close', { uuid }),
/** /**
* The output the model has read so far for one background task (replayed * The output the model has read so far for one background job (replayed
* from the owner session's event log — never the model's task_output * from the owner session's event log — never the model's job_output
* cursor). The scope MUST be the task's OWNER session. * cursor). The scope MUST be the job's OWNER session.
*/ */
taskOutput: (scope: SessionScope, id: string, signal?: AbortSignal) => jobOutput: (scope: SessionScope, id: string, signal?: AbortSignal) =>
call<TaskOutputResult>('tasks.output', scopePayload(scope, { id }), signal), call<JobOutputResult>('jobs.output', scopePayload(scope, { id }), signal),
/** Request cancellation of one background task (live tasks flip to stopping). */ /** Request cancellation of one background job (live jobs flip to stopping). */
taskKill: (scope: SessionScope, id: string, reason?: string) => jobKill: (scope: SessionScope, id: string, reason?: string) =>
call<{ ok: true; outcome: 'requested' | 'already-finished' }>('tasks.kill', scopePayload(scope, { call<{ ok: true; outcome: 'requested' | 'already-finished' }>('jobs.kill', scopePayload(scope, {
id, id,
...(reason !== undefined ? { reason } : {}), ...(reason !== undefined ? { reason } : {}),
})), })),
+4 -4
View File
@@ -110,16 +110,16 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
order: 30, order: 30,
single: true, single: true,
// Declarative settings: the auto-open switches render under this row in // Declarative settings: the auto-open switches render under this row in
// the Side card settings page (the Tasks page's own related settings). // the Side card settings page (the Jobs page's own related settings).
settings: { settings: {
toggles: [{ toggles: [{
key: 'autoOpenSubagent', key: 'autoOpenSubagent',
title: () => t('settingsSubagentTitle'), title: () => t('settingsSubagentTitle'),
desc: () => t('settingsSubagentDesc'), desc: () => t('settingsSubagentDesc'),
}, { }, {
key: 'autoOpenTasks', key: 'autoOpenJobs',
title: () => t('settingsTasksTitle'), title: () => t('settingsJobsTitle'),
desc: () => t('settingsTasksDesc'), desc: () => t('settingsJobsDesc'),
}], }],
}, },
component: ({ ctx, scope, visible, onSubagentJump }) => ( component: ({ ctx, scope, visible, onSubagentJump }) => (
+29
View File
@@ -0,0 +1,29 @@
/**
* Append text to the current session's composer draft through the
* conversation service — the shared path behind the explorer's @-reference
* button and the viewer selection popup. The service is resolved lazily
* through `ctx.get` (the inject-free read the app's own plugins use); a
* missing service or scope degrades to a logged no-op, never a crash.
*/
import type { Context, SidebarConversation } from '../context-types.ts'
/**
* Append `text` to the session's composer draft (space-separated, like the
* @-mentions). Returns false — and logs — when the conversation service or
* the session scope is unavailable.
*/
export function appendToDraft(ctx: Context, sessionId: string, text: string): boolean {
try {
const actx = ctx.sessions.scope(sessionId)
if (actx === undefined) return false
const conversation = ctx.get('conversation') as SidebarConversation | undefined
if (conversation === undefined) return false
const input = conversation.input.for(actx)
const draft = input.state.getSnapshot().draft
input.setDraft(draft.trim() === '' ? text : `${draft} ${text}`)
return true
} catch (error) {
console.warn('[dsh-better-sidebar] draft insert failed:', error)
return false
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ export const IconDiffOutline16 = ({ size = 16, className }: IconProps) => (
) )
/** /**
* Stop glyph for the background-task kill button: a filled square in the * Stop glyph for the background-job kill button: a filled square in the
* app's outline scale (16), the universal "halt this work" mark. * app's outline scale (16), the universal "halt this work" mark.
*/ */
export const IconStopOutline16 = ({ size = 16, className }: IconProps) => ( export const IconStopOutline16 = ({ size = 16, className }: IconProps) => (
+15 -3
View File
@@ -22,12 +22,13 @@ import { registerImeGuard } from './ime-guard.ts'
import { loadPrefs } from './prefs.ts' import { loadPrefs } from './prefs.ts'
import { SideCardSection } from './SideCardSection.tsx' import { SideCardSection } from './SideCardSection.tsx'
import { api } from './api.ts' import { api } from './api.ts'
import { t } from './locales.ts' import { LOCALE_NS, attachLocale, t, zh, en } from './locales.ts'
import css from './sidebar.module.css' import css from './sidebar.module.css'
import './layout.css' import './layout.css'
/** Services required before mounting (provided by the client runtime). */ /** Services required before mounting (provided by the client runtime; the
export const inject = ['slots', 'sessions', 'connection', 'workspaces'] * locale service backs the sidebar's copy — see locales.ts). */
export const inject = ['slots', 'sessions', 'connection', 'workspaces', 'locale']
/** /**
* Error boundary over the sidebar tree: a render error must never blank the * Error boundary over the sidebar tree: a render error must never blank the
@@ -69,6 +70,17 @@ class SidebarBoundary extends Component<{ children: ReactNode }, { error: string
* @param ctx - the client cordis context (slots, sessions). * @param ctx - the client cordis context (slots, sessions).
*/ */
export function apply(ctx: Context): void { export function apply(ctx: Context): void {
// The sidebar follows the DSH i18n system: attach the locale service so
// the module-level t()/isZh() resolve the Host-backed language preference
// (and switch live — the Sidebar root subscribes to it), and register the
// plugin's dictionaries into the shared locale registry. The disposers
// run on fiber disposal, so re-activation (HMR) re-registers cleanly.
attachLocale(ctx.locale)
ctx.effect(() => {
const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh)
const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
return () => { offZh(); offEn() }
}, 'dsh-better-sidebar: dictionaries')
// One store instance per activation: production code creates it only here, // One store instance per activation: production code creates it only here,
// then hands it to the mounted panel and closes over it in the slot // then hands it to the mounted panel and closes over it in the slot
// registrations (the official createXXXStore() factory rule — no // registrations (the official createXXXStore() factory rule — no
+10
View File
@@ -31,6 +31,16 @@
transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out); transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out);
} }
/* When the sidebar is collapsed, the toggle cluster reclaims the top-right
corner. Push the DSH session header's right padding out so its right-aligned
utilities (the "Session log" download capsule) yield the corner instead of
hiding under the cluster. The header default right-pads 28px; the 2-button
cluster spans right 10→70px, so 78px clears it with an 8px gap. The
Sidebar shell toggles the body attribute with the panel open state. */
body[data-dsh-sidebar-collapsed] #root > div > div:nth-child(2) header {
padding-right: 78px;
}
body[data-dsh-sidebar-dragging] #root, body[data-dsh-sidebar-dragging] #root,
body[data-dsh-sidebar-dragging] #root > div > div:nth-child(2) { body[data-dsh-sidebar-dragging] #root > div > div:nth-child(2) {
transition: none; transition: none;
+92 -55
View File
@@ -1,11 +1,16 @@
/** /**
* Minimal zh/en copy for the sidebar. The plugin renders outside the slot * Minimal zh/en copy for the sidebar. The copy follows the DSH i18n system:
* system's locale seat, so it reads the browser language directly (the DSH * the client apply attaches the locale service (`ctx.locale`, provided by
* locale preference and the browser language agree in practice; both default * `@deepseek-ai/dsh-client-locale`) through {@link attachLocale}, and
* from navigator.language anyway). * `t()`/`isZh()` resolve the active locale from it — the Host-backed
* `locale.preference` wins over the raw browser language and switches live.
* Without an attached service (standalone/test compositions) the browser
* language is used, matching the previous behavior. The dictionaries are
* also registered into the DSH locale registry under {@link LOCALE_NS}.
*/ */
const zh = { /** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */
export const zh = {
explorer: '资源管理器', explorer: '资源管理器',
git: '源代码管理', git: '源代码管理',
terminal: '终端', terminal: '终端',
@@ -96,6 +101,7 @@ const zh = {
openEditor: '打开编辑器', openEditor: '打开编辑器',
gitDetail: '查看变更详情', gitDetail: '查看变更详情',
referenceFile: '@文件', referenceFile: '@文件',
addToConversation: '添加到对话',
copyRelative: '复制相对地址', copyRelative: '复制相对地址',
copyAbsolute: '复制绝对地址', copyAbsolute: '复制绝对地址',
download: '下载', download: '下载',
@@ -121,8 +127,8 @@ const zh = {
zoomHint: 'Alt + 滚轮', zoomHint: 'Alt + 滚轮',
settingsSubagentTitle: '检测到子代理时自动展开任务管理页', settingsSubagentTitle: '检测到子代理时自动展开任务管理页',
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开任务管理页;关闭后需手动打开', settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开任务管理页;关闭后需手动打开',
settingsTasksTitle: '有新后台任务时自动展开任务管理页', settingsJobsTitle: '有新后台任务时自动展开后台任务页',
settingsTasksDesc: '当前会话出现新的后台任务时,自动展开侧边栏并打开任务管理页(每个新任务都会触发);关闭后需手动打开', settingsJobsDesc: '当前会话出现新的后台任务时,自动展开侧边栏并打开后台任务页(每个新任务都会触发);关闭后需手动打开',
settingsToolsTitle: '为模型注入终端工具', settingsToolsTitle: '为模型注入终端工具',
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)', settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
settingsBottomTerminalTitle: '底部面板首次展开自动开终端', settingsBottomTerminalTitle: '底部面板首次展开自动开终端',
@@ -182,29 +188,30 @@ const zh = {
subagentDiagUnsupported: '不支持的条目', subagentDiagUnsupported: '不支持的条目',
subagentDiagUnavailable: '不可用', subagentDiagUnavailable: '不可用',
subagentThinking: '思考中…', subagentThinking: '思考中…',
tasks: '后台命令', jobs: '后台任务',
tasksCount: '{count} 个后台命令', jobsCount: '{count} 个后台任务',
tasksCountRunning: '{count} 个后台命令 · {running} 运行中', jobsCountRunning: '{count} 个后台任务 · {running} 运行中',
taskStatusRunning: '运行中', jobStatusRunning: '运行中',
taskStatusStopping: '终止中', jobStatusStopping: '终止中',
taskStatusCompleted: '已完成', jobStatusCompleted: '已完成',
taskStatusKilled: '已终止', jobStatusKilled: '已终止',
taskStatusFailed: '失败', jobStatusFailed: '失败',
taskDurationSeconds: '{seconds} 秒', jobDurationSeconds: '{seconds} 秒',
taskDurationMinutes: '{minutes} 分 {seconds} 秒', jobDurationMinutes: '{minutes} 分 {seconds} 秒',
taskDurationHours: '{hours} 小时 {minutes} 分', jobDurationHours: '{hours} 小时 {minutes} 分',
taskViewOutput: '查看输出', jobViewOutput: '查看输出',
taskHideOutput: '收起输出', jobHideOutput: '收起输出',
taskNoOutput: '暂无输出', jobNoOutput: '暂无输出',
taskNotReadYet: '等待模型读取该任务的输出(模型执行 task_output 后,输出会显示在这里)', jobNotReadYet: '等待模型读取该任务的输出(模型执行 job_output 后,输出会显示在这里)',
taskOutputTruncated: '输出过长,已截断显示', jobOutputTruncated: '输出过长,已截断显示',
taskOutputError: '输出读取失败', jobOutputError: '输出读取失败',
taskKill: '终止', jobKill: '终止',
taskKillConfirm: '再次点击确认终止', jobKillConfirm: '再次点击确认终止',
taskKillError: '终止失败', jobKillError: '终止失败',
} }
const en: Record<keyof typeof zh, string> = { /** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
export const en: Record<keyof typeof zh, string> = {
explorer: 'Explorer', explorer: 'Explorer',
git: 'Source Control', git: 'Source Control',
terminal: 'Terminal', terminal: 'Terminal',
@@ -295,6 +302,7 @@ const en: Record<keyof typeof zh, string> = {
openEditor: 'Open editor', openEditor: 'Open editor',
gitDetail: 'View change details', gitDetail: 'View change details',
referenceFile: '@file', referenceFile: '@file',
addToConversation: 'Add to conversation',
copyRelative: 'Copy relative path', copyRelative: 'Copy relative path',
copyAbsolute: 'Copy absolute path', copyAbsolute: 'Copy absolute path',
download: 'Download', download: 'Download',
@@ -320,8 +328,8 @@ const en: Record<keyof typeof zh, string> = {
zoomHint: 'Alt + wheel', zoomHint: 'Alt + wheel',
settingsSubagentTitle: 'Auto-open the Tasks page when a subagent appears', settingsSubagentTitle: 'Auto-open the Tasks page when a subagent appears',
settingsSubagentDesc: 'Expand the side card and open the Tasks page when the current conversation spawns a new subagent; turn off to open it manually', settingsSubagentDesc: 'Expand the side card and open the Tasks page when the current conversation spawns a new subagent; turn off to open it manually',
settingsTasksTitle: 'Auto-open the Tasks page on a new background task', settingsJobsTitle: 'Auto-open the Jobs page on a new background job',
settingsTasksDesc: 'Expand the side card and open the Tasks page whenever a new background task appears for the current conversation (every new task triggers); turn off to open it manually', settingsJobsDesc: 'Expand the side card and open the Jobs page whenever a new background job appears for the current conversation (every new job triggers); turn off to open it manually',
settingsToolsTitle: 'Inject terminal tools for the model', settingsToolsTitle: 'Inject terminal tools for the model',
settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)', settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)',
settingsBottomTerminalTitle: 'Auto-open a terminal on the bottom panel\'s first expansion', settingsBottomTerminalTitle: 'Auto-open a terminal on the bottom panel\'s first expansion',
@@ -381,35 +389,64 @@ const en: Record<keyof typeof zh, string> = {
subagentDiagUnsupported: 'Unsupported', subagentDiagUnsupported: 'Unsupported',
subagentDiagUnavailable: 'Unavailable', subagentDiagUnavailable: 'Unavailable',
subagentThinking: 'Thinking…', subagentThinking: 'Thinking…',
tasks: 'Background tasks', jobs: 'Background jobs',
tasksCount: '{count} background tasks', jobsCount: '{count} background jobs',
tasksCountRunning: '{count} background tasks · {running} running', jobsCountRunning: '{count} background jobs · {running} running',
taskStatusRunning: 'Running', jobStatusRunning: 'Running',
taskStatusStopping: 'Stopping', jobStatusStopping: 'Stopping',
taskStatusCompleted: 'Completed', jobStatusCompleted: 'Completed',
taskStatusKilled: 'Killed', jobStatusKilled: 'Killed',
taskStatusFailed: 'Failed', jobStatusFailed: 'Failed',
taskDurationSeconds: '{seconds}s', jobDurationSeconds: '{seconds}s',
taskDurationMinutes: '{minutes}m {seconds}s', jobDurationMinutes: '{minutes}m {seconds}s',
taskDurationHours: '{hours}h {minutes}m', jobDurationHours: '{hours}h {minutes}m',
taskViewOutput: 'View output', jobViewOutput: 'View output',
taskHideOutput: 'Hide output', jobHideOutput: 'Hide output',
taskNoOutput: 'No output yet', jobNoOutput: 'No output yet',
taskNotReadYet: 'Waiting for the model to read this task; its output appears here once the model runs task_output', jobNotReadYet: 'Waiting for the model to read this job; its output appears here once the model runs job_output',
taskOutputTruncated: 'Output truncated', jobOutputTruncated: 'Output truncated',
taskOutputError: 'Failed to read output', jobOutputError: 'Failed to read output',
taskKill: 'Kill', jobKill: 'Kill',
taskKillConfirm: 'Click again to confirm kill', jobKillConfirm: 'Click again to confirm kill',
taskKillError: 'Kill failed', jobKillError: 'Kill failed',
} }
/** Translate a copy key in the browser's language (zh → zh, else en). */ /**
* The dictionary namespace this plugin owns in the DSH locale registry
* (`'sidebar'` is taken by DSH's own ui-sidebar, hence this distinct name).
*/
export const LOCALE_NS = 'betterSidebar'
/** The DSH locale service attached by the client apply (absent → browser detection). */
let localeService: { getSnapshot(): { active: string } } | undefined
/**
* Attach (or detach, with undefined) the DSH locale service. The sidebar
* mounts its own React root outside the slot system's locale seat, so the
* service rides this module-level holder: components keep calling the plain
* `t()` function, and the Sidebar root's locale subscription re-renders the
* whole tree on switches.
*/
export function attachLocale(service: { getSnapshot(): { active: string } } | undefined): void {
localeService = service
}
/**
* The active locale id ('zh' | 'en'): the DSH locale service's snapshot when
* attached, else the browser language.
*/
function activeLocale(): string {
return localeService?.getSnapshot().active
?? (typeof navigator !== 'undefined' ? navigator.language : '')
?? 'en'
}
/** Translate a copy key in the active locale (zh → zh, else en). */
export type CopyKey = keyof typeof zh export type CopyKey = keyof typeof zh
/** Translate a copy key; `{name}` placeholders interpolate from `params`. */ /** Translate a copy key; `{name}` placeholders interpolate from `params`. */
export function t(key: CopyKey, params?: Record<string, string | number>): string { export function t(key: CopyKey, params?: Record<string, string | number>): string {
const lang = typeof navigator !== 'undefined' ? navigator.language : 'en' const dict = activeLocale().toLowerCase().startsWith('zh') ? zh : en
const dict = lang.toLowerCase().startsWith('zh') ? zh : en
let text = dict[key] let text = dict[key]
if (params !== undefined) { if (params !== undefined) {
for (const [name, value] of Object.entries(params)) { for (const [name, value] of Object.entries(params)) {
@@ -419,9 +456,9 @@ export function t(key: CopyKey, params?: Record<string, string | number>): strin
return text return text
} }
/** Whether the browser language is Chinese (used for selectors). */ /** Whether the active locale is Chinese (used for selectors). */
export function isZh(): boolean { export function isZh(): boolean {
return typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh') return activeLocale().toLowerCase().startsWith('zh')
} }
/** Format an ISO 8601 author date relative to now (刚刚 / N 分钟前 / N 小时前 / 昨天 / date). */ /** Format an ISO 8601 author date relative to now (刚刚 / N 分钟前 / N 小时前 / 昨天 / date). */
+3 -3
View File
@@ -40,9 +40,9 @@ export function parsePrefs(value: unknown): SidebarPrefs {
autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean' autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean'
? record.autoOpenSubagent ? record.autoOpenSubagent
: SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent, : SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent,
autoOpenTasks: typeof record.autoOpenTasks === 'boolean' autoOpenJobs: typeof record.autoOpenJobs === 'boolean'
? record.autoOpenTasks ? record.autoOpenJobs
: SIDEBAR_PREFS_DEFAULTS.autoOpenTasks, : SIDEBAR_PREFS_DEFAULTS.autoOpenJobs,
agentTerminalTools: typeof record.agentTerminalTools === 'boolean' agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
? record.agentTerminalTools ? record.agentTerminalTools
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools, : SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
+86
View File
@@ -0,0 +1,86 @@
/**
* Pure payload builders for the "add selection to conversation" popup in the
* text viewers (markdown preview + the catch-all code viewer). Everything
* here is string math — no React, no ctx — so the unit tests cover it
* directly.
*
* Insert shape (agreed with the product owner):
* - Selection ≤ SELECTION_LIMIT characters: a fenced code block whose info
* line is `相对路径:起止行` and whose body is the selected text.
* - Selection over the limit: a single plain-text line `相对路径:起止行`
* (no fence, no content).
* - The path is relative to the session cwd (the same projection the
* explorer's @ button uses); an unknown cwd falls back to the absolute
* path.
* - Line numbers: single-line selections write `path:12`, multi-line write
* `path:12-15`. The markdown preview cannot map rendered DOM back to
* source lines directly, so it reverse-searches the selected text in the
* source and only reports lines on an unambiguous hit (see
* {@link linesOfSelection}).
*/
import { relativeTo } from './paths.ts'
/** Max inserted selection length (UTF-16 code units, i.e. JS `.length`). */
export const SELECTION_LIMIT = 500
/** The source line span a selection maps to (1-based, inclusive). */
export interface SelectionLines {
start: number
end: number
}
/**
* The fence info line: `rel[:start[-end]]` — lines are omitted entirely
* when unknown (the preview reverse-search missed).
*/
export function headerOf(path: string, cwd: string | undefined, lines?: SelectionLines): string {
const rel = cwd !== undefined ? relativeTo(cwd, path) : path
if (lines === undefined) return rel
if (lines.end > lines.start) return `${rel}:${lines.start}-${lines.end}`
return `${rel}:${lines.start}`
}
/**
* The full text appended to the composer draft for one selection.
* Over the limit the content is dropped: the plain path line is the whole
* payload (an empty fenced block would just occupy the draft).
*/
export function buildSelectionInsert(
path: string,
cwd: string | undefined,
lines: SelectionLines | undefined,
selected: string,
): string {
const header = headerOf(path, cwd, lines)
if (selected.length > SELECTION_LIMIT) return header
return `\`\`\`${header}\n${selected}\n\`\`\``
}
/** 1-based line number of a character index in a text. */
function lineAt(source: string, index: number): number {
let line = 1
for (let i = 0; i < index && i < source.length; i++) {
if (source[i] === '\n') line++
}
return line
}
/**
* Reverse-map a rendered-DOM selection back to source line numbers. The
* preview selection is plain text (block boundaries come out as `\n`), so
* this is a best-effort substring search: a single trailing newline is
* stripped first (DOM block selections tend to carry one), and only an
* EXACTLY-ONE occurrence yields lines — an ambiguous or missing match
* returns null (the header then carries the path without line numbers).
*/
export function linesOfSelection(source: string, selected: string): SelectionLines | null {
const text = selected.endsWith('\n') ? selected.slice(0, -1) : selected
if (text === '') return null
const at = source.indexOf(text)
if (at === -1) return null
if (source.indexOf(text, at + 1) !== -1) return null
return {
start: lineAt(source, at),
end: lineAt(source, at + Math.max(text.length - 1, 0)),
}
}
+26
View File
@@ -1067,6 +1067,32 @@
font: var(--dsw-font-xs-13); font: var(--dsw-font-xs-13);
} }
/* The floating "add to conversation" button anchored above a text
selection in the preview/code viewers. Portalled to document.body and
position:fixed, so the editor's overflow clips cannot crop it; flat
chrome like the rest of the sidebar (hairline border, layer-2 fill,
hover fill — no shadow). */
.selectionPopup {
position: fixed;
z-index: 60;
transform: translate(-50%, calc(-100% - 8px));
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxxs-strong-11);
white-space: nowrap;
cursor: pointer;
}
.selectionPopup:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Word preview: the document scrolls independently while the zoom control /* Word preview: the document scrolls independently while the zoom control
stays pinned to the bottom edge. */ stays pinned to the bottom edge. */
.editorDocx { .editorDocx {
@@ -1,29 +1,29 @@
/** /**
* Pure derivations for the Subagent page's background-task section. Kept * Pure derivations for the Subagent page's background-job section. Kept
* framework-free so the node test environment can unit-test them: the task * framework-free so the node test environment can unit-test them: the job
* rows arrive through the harness `session/tasks` push mirror * rows arrive through the harness `session/jobs` push mirror
* (`tasksBySession` in the sessions list feed) — nothing here issues * (`jobsBySession` in the sessions list feed) — nothing here issues
* requests, and the row ordering / status mapping mirror the official * requests, and the row ordering / status mapping mirror the official
* ui-task header list. * ui-jobs header list.
*/ */
import type { import type {
SidebarSessionList, SidebarSessionList,
SidebarSessionSummary, SidebarSessionSummary,
SidebarTaskStatus, SidebarJobStatus,
SidebarTaskView, SidebarJobView,
} from '../context-types.ts' } from '../context-types.ts'
import type { CopyKey } from './locales.ts' import type { CopyKey } from './locales.ts'
/** One row of the tasks section: the task plus its owning session's title. */ /** One row of the jobs section: the job plus its owning session's title. */
export interface TreeTask { export interface TreeJob {
ownerSessionId: string ownerSessionId: string
ownerTitle: string ownerTitle: string
task: SidebarTaskView job: SidebarJobView
} }
/** Whether the registry still holds the task open (its duration ticks). */ /** Whether the registry still holds the job open (its duration ticks). */
export function isTaskLive(task: SidebarTaskView): boolean { export function isJobLive(job: SidebarJobView): boolean {
return task.status === 'running' || task.status === 'stopping' return job.status === 'running' || job.status === 'stopping'
} }
/** /**
@@ -31,7 +31,7 @@ export function isTaskLive(task: SidebarTaskView): boolean {
* each session whose uninterrupted subagent-origin chain reaches it — same * each session whose uninterrupted subagent-origin chain reaches it — same
* lineage semantics as {@link countSubagentDescendants}; cycles fail soft). * lineage semantics as {@link countSubagentDescendants}; cycles fail soft).
* Sessions outside the tree (orphans, other trees) are excluded, so the * Sessions outside the tree (orphans, other trees) are excluded, so the
* tasks section never shows foreign work. * jobs section never shows foreign work.
*/ */
export function treeSessionIds( export function treeSessionIds(
byId: SidebarSessionList['byId'], byId: SidebarSessionList['byId'],
@@ -58,67 +58,67 @@ export function treeSessionIds(
} }
/** /**
* Whether a NEW background task appeared for one session between two * Whether a NEW background job appeared for one session between two
* consecutive list snapshots (a task id the previous snapshot lacked). * consecutive list snapshots (a job id the previous snapshot lacked).
* Unlike the subagent auto-open (0 → N only), ANY new task id triggers: the * Unlike the subagent auto-open (0 → N only), ANY new job id triggers: the
* agent may start several tasks over a session, and each new one should * agent may start several jobs over a session, and each new one should
* surface the Tasks page (a fresh page load never triggers — its baseline * surface the Jobs page (a fresh page load never triggers — its baseline
* starts at the current snapshot). * starts at the current snapshot).
*/ */
export function detectNewTask( export function detectNewJob(
prev: SidebarSessionList, prev: SidebarSessionList,
next: SidebarSessionList, next: SidebarSessionList,
sessionId: string, sessionId: string,
): boolean { ): boolean {
const prevIds = new Set((prev.tasksBySession?.[sessionId] ?? []).map(task => task.id)) const prevIds = new Set((prev.jobsBySession?.[sessionId] ?? []).map(job => job.id))
return (next.tasksBySession?.[sessionId] ?? []).some(task => !prevIds.has(task.id)) return (next.jobsBySession?.[sessionId] ?? []).some(job => !prevIds.has(job.id))
} }
/** /**
* Collect the background tasks of the whole current tree, owner-labeled. * Collect the background jobs of the whole current tree, owner-labeled.
* Sessions without a mirror entry contribute nothing; an absent mirror * Sessions without a mirror entry contribute nothing; an absent mirror
* (runtime older than the tasks feed) yields an empty list. * (runtime older than the jobs feed) yields an empty list.
*/ */
export function collectTreeTasks( export function collectTreeJobs(
byId: SidebarSessionList['byId'], byId: SidebarSessionList['byId'],
tasksBySession: Readonly<Record<string, readonly SidebarTaskView[]>> | undefined, jobsBySession: Readonly<Record<string, readonly SidebarJobView[]>> | undefined,
rootId: string | undefined, rootId: string | undefined,
): TreeTask[] { ): TreeJob[] {
const rows: TreeTask[] = [] const rows: TreeJob[] = []
if (tasksBySession === undefined) return rows if (jobsBySession === undefined) return rows
for (const sessionId of treeSessionIds(byId, rootId)) { for (const sessionId of treeSessionIds(byId, rootId)) {
const tasks = tasksBySession[sessionId] const jobs = jobsBySession[sessionId]
if (tasks === undefined || tasks.length === 0) continue if (jobs === undefined || jobs.length === 0) continue
const ownerTitle = byId[sessionId]?.displayTitle ?? sessionId const ownerTitle = byId[sessionId]?.displayTitle ?? sessionId
for (const task of tasks) rows.push({ ownerSessionId: sessionId, ownerTitle, task }) for (const job of jobs) rows.push({ ownerSessionId: sessionId, ownerTitle, job })
} }
return rows return rows
} }
/** /**
* Live rows first in start order, then settled rows newest-first (mirror of * Live rows first in start order, then settled rows newest-first (mirror of
* the official ui-task ordering); a tie falls back to start order so the * the official ui-jobs ordering); a tie falls back to start order so the
* sort never depends on the host's map iteration. * sort never depends on the host's map iteration.
*/ */
export function orderTasks(rows: readonly TreeTask[]): TreeTask[] { export function orderJobs(rows: readonly TreeJob[]): TreeJob[] {
return [...rows].sort((left, right) => { return [...rows].sort((left, right) => {
const liveLeft = isTaskLive(left.task) const liveLeft = isJobLive(left.job)
if (liveLeft !== isTaskLive(right.task)) return liveLeft ? -1 : 1 if (liveLeft !== isJobLive(right.job)) return liveLeft ? -1 : 1
if (liveLeft) return left.task.startedAt - right.task.startedAt if (liveLeft) return left.job.startedAt - right.job.startedAt
const finished = (right.task.finishedAt ?? right.task.startedAt) - (left.task.finishedAt ?? left.task.startedAt) const finished = (right.job.finishedAt ?? right.job.startedAt) - (left.job.finishedAt ?? left.job.startedAt)
return finished !== 0 ? finished : left.task.startedAt - right.task.startedAt return finished !== 0 ? finished : left.job.startedAt - right.job.startedAt
}) })
} }
/** The sidebar's StateDot states for the five wire statuses. */ /** The sidebar's StateDot states for the five wire statuses. */
export type TaskDotState = 'ongoing' | 'warning' | 'done' | 'error' export type JobDotState = 'ongoing' | 'warning' | 'done' | 'error'
/** /**
* Status marker semantics. `stopping` and `killed` share the attention * Status marker semantics. `stopping` and `killed` share the attention
* color: both mean the work ended (or is ending) on request rather than on * color: both mean the work ended (or is ending) on request rather than on
* its own. * its own.
*/ */
export function taskDotState(status: SidebarTaskStatus): TaskDotState { export function jobDotState(status: SidebarJobStatus): JobDotState {
switch (status) { switch (status) {
case 'running': return 'ongoing' case 'running': return 'ongoing'
case 'stopping': return 'warning' case 'stopping': return 'warning'
@@ -129,25 +129,25 @@ export function taskDotState(status: SidebarTaskStatus): TaskDotState {
} }
/** Human status word of one wire status (localized through the passed translator). */ /** Human status word of one wire status (localized through the passed translator). */
export function taskStatusLabel( export function jobStatusLabel(
status: SidebarTaskStatus, status: SidebarJobStatus,
t: (key: CopyKey, params?: Record<string, string | number>) => string, t: (key: CopyKey, params?: Record<string, string | number>) => string,
): string { ): string {
switch (status) { switch (status) {
case 'running': return t('taskStatusRunning') case 'running': return t('jobStatusRunning')
case 'stopping': return t('taskStatusStopping') case 'stopping': return t('jobStatusStopping')
case 'completed': return t('taskStatusCompleted') case 'completed': return t('jobStatusCompleted')
case 'killed': return t('taskStatusKilled') case 'killed': return t('jobStatusKilled')
case 'failed': return t('taskStatusFailed') case 'failed': return t('jobStatusFailed')
} }
} }
/** /**
* Elapsed time in at most two adjacent units (mirror of the official * Elapsed time in at most two adjacent units (mirror of the official
* ui-task duration wording). A background task that outlives an hour is * ui-jobs duration wording). A background job that outlives an hour is
* already exceptional, so hours is the widest unit. * already exceptional, so hours is the widest unit.
*/ */
export function formatTaskDuration( export function formatJobDuration(
elapsedMs: number, elapsedMs: number,
t: (key: CopyKey, params?: Record<string, string | number>) => string, t: (key: CopyKey, params?: Record<string, string | number>) => string,
): string { ): string {
@@ -155,7 +155,7 @@ export function formatTaskDuration(
const seconds = total % 60 const seconds = total % 60
const minutes = Math.floor(total / 60) % 60 const minutes = Math.floor(total / 60) % 60
const hours = Math.floor(total / 3_600) const hours = Math.floor(total / 3_600)
if (hours > 0) return t('taskDurationHours', { hours, minutes }) if (hours > 0) return t('jobDurationHours', { hours, minutes })
if (minutes > 0) return t('taskDurationMinutes', { minutes, seconds }) if (minutes > 0) return t('jobDurationMinutes', { minutes, seconds })
return t('taskDurationSeconds', { seconds }) return t('jobDurationSeconds', { seconds })
} }
+5 -5
View File
@@ -11,7 +11,7 @@
*/ */
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { mediaUrl } from './api.ts' import { mediaUrl } from './api.ts'
import { t } from './locales.ts' import { isZh, t } from './locales.ts'
import { BinaryFallback, type LoadState, type OfficeViewProps } from './office-shared.tsx' import { BinaryFallback, type LoadState, type OfficeViewProps } from './office-shared.tsx'
import { xlsxWorkbookToUniver } from './xlsx-to-univer.ts' import { xlsxWorkbookToUniver } from './xlsx-to-univer.ts'
import css from './sidebar.module.css' import css from './sidebar.module.css'
@@ -54,14 +54,14 @@ export function XlsxView(props: OfficeViewProps): JSX.Element {
const XLSX = await import('xlsx') const XLSX = await import('xlsx')
const { createUniver, LocaleType, mergeLocales } = await import('@univerjs/presets') const { createUniver, LocaleType, mergeLocales } = await import('@univerjs/presets')
const { UniverSheetsCorePreset } = await import('@univerjs/preset-sheets-core') const { UniverSheetsCorePreset } = await import('@univerjs/preset-sheets-core')
// Locales pick the browser language; falls back to en-US. // Locales follow the DSH i18n system (the active locale); falls
const isZh = typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh') // back to the browser language, then en-US.
const localePack = await (isZh const localePack = await (isZh()
? import('@univerjs/preset-sheets-core/locales/zh-CN').then(m => m.default).catch(() => null) ? import('@univerjs/preset-sheets-core/locales/zh-CN').then(m => m.default).catch(() => null)
: import('@univerjs/preset-sheets-core/locales/en-US').then(m => m.default).catch(() => null)) : import('@univerjs/preset-sheets-core/locales/en-US').then(m => m.default).catch(() => null))
const wb = XLSX.read(buf, { type: 'array' }) const wb = XLSX.read(buf, { type: 'array' })
const locale = isZh ? LocaleType.ZH_CN : LocaleType.EN_US const locale = isZh() ? LocaleType.ZH_CN : LocaleType.EN_US
const workbookData = xlsxWorkbookToUniver(wb, '0.25.1', locale) const workbookData = xlsxWorkbookToUniver(wb, '0.25.1', locale)
if (cancelled) return if (cancelled) return
+1 -1
View File
@@ -79,7 +79,7 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
openByDefault: z.boolean().default(true), openByDefault: z.boolean().default(true),
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT), defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
autoOpenSubagent: z.boolean().default(true), autoOpenSubagent: z.boolean().default(true),
autoOpenTasks: z.boolean().default(true), autoOpenJobs: z.boolean().default(true),
agentTerminalTools: z.boolean().default(false), agentTerminalTools: z.boolean().default(false),
bottomPanelAutoTerminal: z.boolean().default(true), bottomPanelAutoTerminal: z.boolean().default(true),
interceptOpenPath: z.boolean().default(true), interceptOpenPath: z.boolean().default(true),
+51 -28
View File
@@ -6,14 +6,14 @@
* the npm cordis package does not declare the DSH-vendored runtime members * the npm cordis package does not declare the DSH-vendored runtime members
* (`ctx.effect`, service properties). The members below mirror the actual * (`ctx.effect`, service properties). The members below mirror the actual
* runtime shapes this plugin touches: * runtime shapes this plugin touches:
* - httpServer: @deepseek-ai/dsh-host-webserver * - webServer: @deepseek-ai/dsh-host-webserver (the WebServer)
* - sessions: host side @deepseek-ai/dsh-session (SessionStore), client * - sessions: host side @deepseek-ai/dsh-session (SessionStore), client
* side the runtime ISessions list feed * side the runtime ISessions list feed
* - conversation: client side ui-conversation's IConversation (composer * - conversation: client side ui-conversation's IConversation (composer
* draft), read lazily through `ctx.get` — cross-plugin service reads need * draft), read lazily through `ctx.get` — cross-plugin service reads need
* an inject declaration, so the direct property is never typed here * an inject declaration, so the direct property is never typed here
* - loader: @cordisjs/plugin-loader (entry options) * - loader: @cordisjs/plugin-loader (entry options)
* - slots: the client runtime SlotsService * - slots: the client runtime SlotRegistry
* - effect: the DSH-vendored cordis lifecycle helper * - effect: the DSH-vendored cordis lifecycle helper
* Drift from upstream is contained to this file. * Drift from upstream is contained to this file.
*/ */
@@ -35,8 +35,8 @@ export interface SidebarWebUpgradeRoute {
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void> handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
} }
/** The httpServer service face this plugin uses. */ /** The webServer service face this plugin uses. */
export interface SidebarHttpServer { export interface SidebarWebServer {
register(route: SidebarWebRoute): () => void register(route: SidebarWebRoute): () => void
registerUpgrade(route: SidebarWebUpgradeRoute): () => void registerUpgrade(route: SidebarWebUpgradeRoute): () => void
} }
@@ -53,7 +53,7 @@ export interface SidebarSessionStore {
/** /**
* The live session's append-only event log (immutable snapshot; absent * The live session's append-only event log (immutable snapshot; absent
* on sessions the runtime has not hydrated). Read-only access — the * on sessions the runtime has not hydrated). Read-only access — the
* tasks.output route replays `task_output` tool/result rows from it. * jobs.output route replays `job_output` tool/result rows from it.
*/ */
events?: readonly SidebarSessionEvent[] events?: readonly SidebarSessionEvent[]
} | undefined } | undefined
@@ -91,7 +91,7 @@ export interface SidebarSlotsService {
register(options: SidebarSlotRegisterOptions, component: unknown): () => void register(options: SidebarSlotRegisterOptions, component: unknown): () => void
/** /**
* Run a callback for each declaration lifetime of a slot (the runtime * Run a callback for each declaration lifetime of a slot (the runtime
* SlotsService.inject): a no-op while the slot is undeclared, so the * SlotRegistry.inject): a no-op while the slot is undeclared, so the
* settings section registration waits for the settings shell. * settings section registration waits for the settings shell.
*/ */
inject(key: string, callback: () => () => void): () => void inject(key: string, callback: () => () => void): () => void
@@ -158,33 +158,33 @@ export interface SidebarHistoryEntry {
view?: unknown view?: unknown
} }
/** Lifecycle status set of one background task (closed wire union). */ /** Lifecycle status set of one background job (closed wire union). */
export type SidebarTaskStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed' export type SidebarJobStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
/** /**
* One background task as the client mirror sees it (wire `TaskView` shape: * One background job as the client mirror sees it (wire `JobView` shape:
* id/kind/label/status/detail?/startedAt/finishedAt?). * id/kind/label/status/detail?/startedAt/finishedAt?).
*/ */
export interface SidebarTaskView { export interface SidebarJobView {
/** Registry-issued `<kind>-N` identity, stable for the task's whole life. */ /** Registry-issued `<kind>-N` identity, stable for the job's whole life. */
id: string id: string
/** Producer kind (`bash`, `pwsh`, `subagent`, …; open string by design). */ /** Producer kind (`bash`, `pwsh`, `subagent`, …; open string by design). */
kind: string kind: string
/** Producer-supplied one-line label: the command, or the delegation description. */ /** Producer-supplied one-line label: the command, or the delegation description. */
label: string label: string
/** Current lifecycle state. */ /** Current lifecycle state. */
status: SidebarTaskStatus status: SidebarJobStatus
/** Kind-specific status detail ('exit code: 3'), present once supplied. */ /** Kind-specific status detail ('exit code: 3'), present once supplied. */
detail?: string detail?: string
/** Epoch ms when the task was registered. */ /** Epoch ms when the job was registered. */
startedAt: number startedAt: number
/** Epoch ms when the task settled; absent while live. */ /** Epoch ms when the job settled; absent while live. */
finishedAt?: number finishedAt?: number
} }
/** The host tasks registry face the sidebar routes touch (structural mirror of `TaskService`). */ /** The host jobs registry face the sidebar routes touch (structural mirror of `JobRegistry`). */
export interface SidebarTasksService { export interface SidebarJobsService {
/** Request cancellation; throws for an unknown or foreign task. */ /** Request cancellation; throws for an unknown or foreign job. */
kill(id: string, caller?: SidebarAgent, reason?: string): 'requested' | 'already-finished' kill(id: string, caller?: SidebarAgent, reason?: string): 'requested' | 'already-finished'
} }
@@ -222,11 +222,11 @@ export interface SidebarSessionList {
/** Direct durable catalogs keyed by their selected parent address. */ /** Direct durable catalogs keyed by their selected parent address. */
subagentsByParent?: Readonly<Record<string, SidebarSubagentCatalog>> subagentsByParent?: Readonly<Record<string, SidebarSubagentCatalog>>
/** /**
* Background tasks per session, last-wins from the harness's `session/tasks` * Background jobs per session, last-wins from the harness's `session/jobs`
* push (a missing key is an empty set). Absent on runtime snapshots older * push (a missing key is an empty set). Absent on runtime snapshots older
* than the tasks mirror — the sidebar simply shows no task rows. * than the jobs mirror — the sidebar simply shows no job rows.
*/ */
tasksBySession?: Readonly<Record<string, readonly SidebarTaskView[]>> jobsBySession?: Readonly<Record<string, readonly SidebarJobView[]>>
} }
/** The client sessions service face (only the list feed is needed). */ /** The client sessions service face (only the list feed is needed). */
@@ -265,6 +265,23 @@ export interface SidebarSessionsService {
refreshSubagents?(parentSessionId: string): Promise<void> refreshSubagents?(parentSessionId: string): Promise<void>
} }
/**
* The client locale service face (mirror of @deepseek-ai/dsh-client-locale's
* LocaleRuntime — only the slices the sidebar touches). The sidebar follows
* the DSH i18n system: the active locale is the Host-backed preference
* (`locale.preference` in settings.yaml) rather than the raw browser
* language, and the sidebar's zh/en dictionaries register into the service's
* namespace registry under `betterSidebar`.
*/
export interface SidebarLocaleService {
/** Current immutable locale snapshot (uSES-safe; `active` is 'zh' | 'en' today). */
getSnapshot(): { active: string }
/** Subscribe to snapshot changes (locale switch or dictionary registration). */
subscribe(fn: () => void): () => void
/** Register one locale's dictionary for a namespace; returns the disposer. */
register(ns: string, locale: string, dict: Record<string, string>): () => void
}
/** The composer draft face the sidebar reaches through `ctx.conversation.input`. */ /** The composer draft face the sidebar reaches through `ctx.conversation.input`. */
export interface SidebarSessionInput { export interface SidebarSessionInput {
/** The live input store (draft read for append). */ /** The live input store (draft read for append). */
@@ -295,7 +312,7 @@ export interface SidebarWorkspacesService {
/** /**
* The invariant service face (mirror of @deepseek-ai/dsh-invariants' * The invariant service face (mirror of @deepseek-ai/dsh-invariants'
* InvariantService). The upstream augmentation does not reach this Context * InvariantRegistry). The upstream augmentation does not reach this Context
* (dual-cordis-instance resolution), so the register signature is restated * (dual-cordis-instance resolution), so the register signature is restated
* structurally, exactly like the other service faces above. * structurally, exactly like the other service faces above.
*/ */
@@ -307,7 +324,7 @@ export interface SidebarInvariantsService {
): () => void ): () => void
} }
/** The settings service face (mirror of @deepseek-ai/dsh-settings' Settings). */ /** The settings service face (mirror of @deepseek-ai/dsh-settings' SettingsProvider). */
export interface SidebarSettingsService { export interface SidebarSettingsService {
/** /**
* Register one namespace schema (the resolved value layers schema defaults, * Register one namespace schema (the resolved value layers schema defaults,
@@ -337,7 +354,7 @@ export interface SidebarSettingsService {
} }
/** /**
* The tools service face (mirror of @deepseek-ai/dsh-tools' ToolRegistry). * The tools service face (mirror of @deepseek-ai/dsh-tools' ToolRuntime).
* The host half registers model-facing tools here; the registry attaches the * The host half registers model-facing tools here; the registry attaches the
* returned disposer to the contributing fiber so unloading unregisters them. * returned disposer to the contributing fiber so unloading unregisters them.
*/ */
@@ -363,7 +380,7 @@ export interface SidebarAgent {
declare module 'cordis' { declare module 'cordis' {
interface Context { interface Context {
httpServer: SidebarHttpServer webServer: SidebarWebServer
sessions: SidebarSessionStore & SidebarSessionsService sessions: SidebarSessionStore & SidebarSessionsService
connection: SidebarConnectionHandle connection: SidebarConnectionHandle
loader: SidebarLoader loader: SidebarLoader
@@ -373,13 +390,19 @@ declare module 'cordis' {
invariants: SidebarInvariantsService invariants: SidebarInvariantsService
tools: SidebarToolsService tools: SidebarToolsService
/** /**
* The host background-task registry (`ctx.get('tasks')`; optional — the * The client locale service (`@deepseek-ai/dsh-client-locale`): the
* sidebar's copy follows its active locale and registers its
* dictionaries under the `betterSidebar` namespace. Client side only.
*/
locale: SidebarLocaleService
/**
* The host background-job registry (`ctx.get('jobs')`; optional — the
* sidebar routes degrade to a 503 when the deployment lacks it). * sidebar routes degrade to a 503 when the deployment lacks it).
*/ */
tasks: SidebarTasksService jobs: SidebarJobsService
/** /**
* The host live-agent registry (`ctx.get('agents')`; optional — used to * The host live-agent registry (`ctx.get('agents')`; optional — used to
* resolve the caller the tasks fence compares against). * resolve the caller the jobs fence compares against).
*/ */
agents: SidebarAgentsService agents: SidebarAgentsService
/** /**
@@ -392,7 +415,7 @@ declare module 'cordis' {
* Subscribe to the session append feed (mirror of the cordis event API): * Subscribe to the session append feed (mirror of the cordis event API):
* the listener receives every appended session event with the LIVE * the listener receives every appended session event with the LIVE
* Session instance that appended it. The api-proxy pushes the same feed * Session instance that appended it. The api-proxy pushes the same feed
* to browsers; the sidebar uses it to mirror task_output events the * to browsers; the sidebar uses it to mirror job_output events the
* session store's own log can lag behind (restart divergence). Returns * session store's own log can lag behind (restart divergence). Returns
* the disposer. * the disposer.
*/ */
+19 -19
View File
@@ -36,7 +36,7 @@ import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts' import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts' import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
import { registerTools } from './tools.ts' import { registerTools } from './tools.ts'
import { buildTasksApi, type SidebarTasksRoutes } from './tasks-routes.ts' import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts' import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
export { Config } export { Config }
@@ -59,7 +59,7 @@ export type {
export const name = 'dsh-better-sidebar' export const name = 'dsh-better-sidebar'
/** Services required before mounting: the webserver routes, the session store, the loader's connection row, and the tool registry. */ /** Services required before mounting: the webserver routes, the session store, the loader's connection row, and the tool registry. */
export const inject = ['httpServer', 'sessions', 'loader', 'tools'] export const inject = ['webServer', 'sessions', 'loader', 'tools']
/** Content types for the media route, by extension. */ /** Content types for the media route, by extension. */
const MEDIA_TYPES: Record<string, string> = { const MEDIA_TYPES: Record<string, string> = {
@@ -199,12 +199,12 @@ function buildApi(
const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined
return { sessionId, cwd: sessionCwdOf(ctx, sessionId, clientCwd) } return { sessionId, cwd: sessionCwdOf(ctx, sessionId, clientCwd) }
} }
// Background tasks: the LIST rides the harness's `session/tasks` push // Background jobs: the LIST rides the harness's `session/jobs` push
// mirror, so these routes only replay output the model has read (from the // mirror, so these routes only replay output the model has read (from the
// session's own event log — no DSH source is touched, the model's // session's own event log — no DSH source is touched, the model's
// task_output cursor is never consumed) and kill (the registry's stock // job_output cursor is never consumed) and kill (the registry's stock
// API). A deployment without the tasks registry downgrades kill to a 503. // API). A deployment without the jobs registry downgrades kill to a 503.
const tasksApi: SidebarTasksRoutes = buildTasksApi(ctx, resolved.readLimit) const jobsApi: SidebarJobsRoutes = buildJobsApi(ctx, resolved.readLimit)
return { return {
'session.cwd': (payload) => { 'session.cwd': (payload) => {
const { sessionId, cwd } = cwdOf(payload) const { sessionId, cwd } = cwdOf(payload)
@@ -334,14 +334,14 @@ function buildApi(
agentPtyRegistry.close(uuid) agentPtyRegistry.close(uuid)
return { ok: true } return { ok: true }
}, },
// Background tasks: read one task's output (a REPLAY of what the model // Background jobs: read one job's output (a REPLAY of what the model
// has read so far, from the owner session's event log — the model's // has read so far, from the owner session's event log — the model's
// task_output cursor is never touched, so the human pane can never steal // job_output cursor is never touched, so the human pane can never steal
// the agent's bytes), and kill one task. The task LIST itself arrives // the agent's bytes), and kill one job. The job LIST itself arrives
// through the harness's session/tasks push mirror, so no list route // through the harness's session/jobs push mirror, so no list route
// exists. Kill is fenced to the owning session by the tasks registry. // exists. Kill is fenced to the owning session by the jobs registry.
'tasks.output': (payload) => tasksApi.output(payload), 'jobs.output': (payload) => jobsApi.output(payload),
'tasks.kill': (payload) => tasksApi.kill(payload), 'jobs.kill': (payload) => jobsApi.kill(payload),
// The side card preferences. The settings service is optional in the // The side card preferences. The settings service is optional in the
// composition; while absent the routes report undefined and the client // composition; while absent the routes report undefined and the client
// keeps the schema defaults. Writes are revision-guarded: a stale editor // keeps the schema defaults. Writes are revision-guarded: a stale editor
@@ -425,7 +425,7 @@ function buildApi(
/** /**
* Plugin body: mount the fenced routes and the pty lifecycle. * Plugin body: mount the fenced routes and the pty lifecycle.
* @param ctx - host plugin context (httpServer, sessions, loader). * @param ctx - host plugin context (webServer, sessions, loader).
* @param config - deployment-provided limits; the Loader validates against * @param config - deployment-provided limits; the Loader validates against
* {@link Config} and fills defaults, direct callers get them from * {@link Config} and fills defaults, direct callers get them from
* {@link resolveSidebarConfig}. * {@link resolveSidebarConfig}.
@@ -503,7 +503,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
// ── JSON API ──────────────────────────────────────────────────────────── // ── JSON API ────────────────────────────────────────────────────────────
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, () => settingsFace) const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, () => settingsFace)
ctx.effect(() => ctx.httpServer.register({ ctx.effect(() => ctx.webServer.register({
kind: 'prefix', kind: 'prefix',
path: '/sidebar/api', path: '/sidebar/api',
handler: async (req, res) => { handler: async (req, res) => {
@@ -541,7 +541,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
ctx.effect(() => registerBundleRoute(ctx, fence), 'dsh-better-sidebar: /sidebar/bundle chunk route') ctx.effect(() => registerBundleRoute(ctx, fence), 'dsh-better-sidebar: /sidebar/bundle chunk route')
// ── Media route (images for the editor) ───────────────────────────────── // ── Media route (images for the editor) ─────────────────────────────────
ctx.effect(() => ctx.httpServer.register({ ctx.effect(() => ctx.webServer.register({
kind: 'prefix', kind: 'prefix',
path: '/sidebar/file', path: '/sidebar/file',
handler: async (req, res) => { handler: async (req, res) => {
@@ -599,7 +599,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
// boundary, this header is defense-in-depth so even a top-level load of // boundary, this header is defense-in-depth so even a top-level load of
// the URL (e.g. a popup opened by a previewed page) stays in an opaque // the URL (e.g. a popup opened by a previewed page) stays in an opaque
// origin with no same-origin access to the GUI. // origin with no same-origin access to the GUI.
ctx.effect(() => ctx.httpServer.register({ ctx.effect(() => ctx.webServer.register({
kind: 'prefix', kind: 'prefix',
path: '/sidebar/html', path: '/sidebar/html',
handler: async (req, res) => { handler: async (req, res) => {
@@ -662,7 +662,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
// terminals, scheduled-0 for UI tabs which keep the same reconnect grace // terminals, scheduled-0 for UI tabs which keep the same reconnect grace
// contract the host has always had). // contract the host has always had).
const wss = new WebSocketServer({ noServer: true }) const wss = new WebSocketServer({ noServer: true })
ctx.effect(() => ctx.httpServer.registerUpgrade({ ctx.effect(() => ctx.webServer.registerUpgrade({
path: '/sidebar/ws/terminal', path: '/sidebar/ws/terminal',
handler: (req, socket, head) => { handler: (req, socket, head) => {
if (!fence(req)) { if (!fence(req)) {
@@ -684,7 +684,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
// sends `{type:'close'}` on the terminal WS, which kills the pty, which // sends `{type:'close'}` on the terminal WS, which kills the pty, which
// fires a change here, which converges the view). // fires a change here, which converges the view).
const agentListWss = new WebSocketServer({ noServer: true }) const agentListWss = new WebSocketServer({ noServer: true })
ctx.effect(() => ctx.httpServer.registerUpgrade({ ctx.effect(() => ctx.webServer.registerUpgrade({
path: '/sidebar/ws/agent-terminals', path: '/sidebar/ws/agent-terminals',
handler: (req, socket, head) => { handler: (req, socket, head) => {
if (!fence(req)) { if (!fence(req)) {
+1 -1
View File
@@ -15,7 +15,7 @@ export const inject = ['invariants']
/** /**
* No runtime invariant: the sidebar owns no service state or event protocol * No runtime invariant: the sidebar owns no service state or event protocol
* of its own — every route is mounted under the host's httpServer fence, the * of its own — every route is mounted under the host's webServer fence, the
* pty lifecycle is exercised by the smoke spec, and the panel's store is a * pty lifecycle is exercised by the smoke spec, and the panel's store is a
* plain snapshot registry asserted by the behavior specs. The route fence, * plain snapshot registry asserted by the behavior specs. The route fence,
* pty quota, and store semantics are each observed through their seams. * pty quota, and store semantics are each observed through their seams.
+50 -50
View File
@@ -1,31 +1,31 @@
/** /**
* Background-task routes of the /sidebar JSON API ('tasks.output' / * Background-job routes of the /sidebar JSON API ('jobs.output' /
* 'tasks.kill'). The task LIST needs no route: it arrives through the * 'jobs.kill'). The job LIST needs no route: it arrives through the
* harness's `session/tasks` push mirror (`tasksBySession` in the sessions * harness's `session/jobs` push mirror (`jobsBySession` in the sessions
* list feed). The routes: * list feed). The routes:
* *
* - 'tasks.output' — REPLAYS the output the MODEL has read so far for one * - 'jobs.output' — REPLAYS the output the MODEL has read so far for one
* task. The source is the owner session's own event log: `tool/call` rows * job. The source is the owner session's own event log: `tool/call` rows
* of `task_output` name the task via `arguments.task_id`, and the paired * of `job_output` name the job via `arguments.job_id`, and the paired
* `tool/result` rows carry the finalized content the model received. * `tool/result` rows carry the finalized content the model received.
* Because the session store's in-memory log can lag the live append feed * Because the session store's in-memory log can lag the live append feed
* after a host restart (the store session stays frozen at its * after a host restart (the store session stays frozen at its
* rehydration boundary), the plugin ALSO mirrors task_output events from * rehydration boundary), the plugin ALSO mirrors job_output events from
* the live `session/event` feed and merges both sources (deduped by seq). * the live `session/event` feed and merges both sources (deduped by seq).
* This touches NO DSH source: the model's `task_output` cursor is never * This touches NO DSH source: the model's `job_output` cursor is never
* consumed, and the pane stays empty until the agent reads the task. * consumed, and the pane stays empty until the agent reads the job.
* - 'tasks.kill' — the registry's stock `kill` (a pristine DSH API), * - 'jobs.kill' — the registry's stock `kill` (a pristine DSH API),
* fenced by the owning session via the live agent caller. Absent registry * fenced by the owning session via the live agent caller. Absent registry
* → 503, mirroring the settings routes' optional-service downgrade. * → 503, mirroring the settings routes' optional-service downgrade.
*/ */
import type { Context, SidebarSessionEvent } from './context-types.ts' import type { Context, SidebarSessionEvent } from './context-types.ts'
import { requireString, SidebarError } from './wire.ts' import { requireString, SidebarError } from './wire.ts'
/** The two background-task routes of the sidebar API. */ /** The two background-job routes of the sidebar API. */
export interface SidebarTasksRoutes { export interface SidebarJobsRoutes {
/** The output the model has read so far for one task (event replay, capped). */ /** The output the model has read so far for one job (event replay, capped). */
output(payload: unknown): { text: string; truncated: boolean; read: boolean } output(payload: unknown): { text: string; truncated: boolean; read: boolean }
/** Request cancellation of one task (live tasks flip to stopping). */ /** Request cancellation of one job (live jobs flip to stopping). */
kill(payload: unknown): { ok: true; outcome: 'requested' | 'already-finished' } kill(payload: unknown): { ok: true; outcome: 'requested' | 'already-finished' }
} }
@@ -77,40 +77,40 @@ function resultIsError(message: ToolResultMessageLike): boolean {
}) })
} }
/** Whether a task_output result carries no new output — the controller's /** Whether a job_output result carries no new output — the controller's
* model-facing "(no new output)" body, noise for the human pane. */ * model-facing "(no new output)" body, noise for the human pane. */
function isNoNewOutput(text: string): boolean { function isNoNewOutput(text: string): boolean {
return text.startsWith('(no new output)') return text.startsWith('(no new output)')
} }
/** One compact task_output trace (a tool/call or its paired tool/result). */ /** One compact job_output trace (a tool/call or its paired tool/result). */
interface TaskOutputTrace { interface JobOutputTrace {
seq: number seq: number
kind: 'call' | 'result' kind: 'call' | 'result'
/** The tool call identity pairing the two rows. */ /** The tool call identity pairing the two rows. */
callId: string callId: string
/** tool/call: the task id parsed from the model arguments. */ /** tool/call: the job id parsed from the model arguments. */
taskId?: string jobId?: string
/** tool/result: the finalized text the model received. */ /** tool/result: the finalized text the model received. */
text?: string text?: string
/** tool/result: whether the result was an error (read counts, text skipped). */ /** tool/result: whether the result was an error (read counts, text skipped). */
isError?: boolean isError?: boolean
} }
/** Extract the task_output trace of one raw session event (undefined = unrelated). */ /** Extract the job_output trace of one raw session event (undefined = unrelated). */
function traceOf(event: SidebarSessionEvent): TaskOutputTrace | undefined { function traceOf(event: SidebarSessionEvent): JobOutputTrace | undefined {
if (event.type === 'tool/call') { if (event.type === 'tool/call') {
const data = event.data as { name?: unknown; callId?: unknown; arguments?: unknown } const data = event.data as { name?: unknown; callId?: unknown; arguments?: unknown }
if (data.name !== 'task_output' || typeof data.callId !== 'string') return undefined if (data.name !== 'job_output' || typeof data.callId !== 'string') return undefined
let taskId: string | undefined let jobId: string | undefined
try { try {
const args = JSON.parse(typeof data.arguments === 'string' ? data.arguments : '') as { task_id?: unknown } const args = JSON.parse(typeof data.arguments === 'string' ? data.arguments : '') as { job_id?: unknown }
if (typeof args.task_id === 'string') taskId = args.task_id if (typeof args.job_id === 'string') jobId = args.job_id
} catch { } catch {
// Malformed model arguments: not a task_output pair. // Malformed model arguments: not a job_output pair.
} }
if (taskId === undefined) return undefined if (jobId === undefined) return undefined
return { seq: event.seq, kind: 'call', callId: data.callId, taskId } return { seq: event.seq, kind: 'call', callId: data.callId, jobId }
} }
if (event.type === 'tool/result') { if (event.type === 'tool/result') {
const message = (event.data as { message?: unknown }).message as ToolResultMessageLike | undefined const message = (event.data as { message?: unknown }).message as ToolResultMessageLike | undefined
@@ -132,17 +132,17 @@ function traceOf(event: SidebarSessionEvent): TaskOutputTrace | undefined {
const MIRROR_MAX_ENTRIES = 200 const MIRROR_MAX_ENTRIES = 200
/** /**
* The live task_output mirror: subscribes to the session append feed and * The live job_output mirror: subscribes to the session append feed and
* caches the task_output traces the session store's own log can lag behind * caches the job_output traces the session store's own log can lag behind
* (after a host restart the store session stays frozen at its rehydration * (after a host restart the store session stays frozen at its rehydration
* boundary, so `session.events` misses everything appended since — the very * boundary, so `session.events` misses everything appended since — the very
* reads the pane exists to show). Zero DSH writes: the api-proxy pushes the * reads the pane exists to show). Zero DSH writes: the api-proxy pushes the
* same feed to browsers. * same feed to browsers.
*/ */
function createTaskOutputMirror(ctx: Context): { entries(sessionId: string): readonly TaskOutputTrace[] } { function createJobOutputMirror(ctx: Context): { entries(sessionId: string): readonly JobOutputTrace[] } {
const perSession = new Map<string, TaskOutputTrace[]>() const perSession = new Map<string, JobOutputTrace[]>()
// tool/call identities per session, so unrelated tool/result rows are // tool/call identities per session, so unrelated tool/result rows are
// never cached (only task_output results pair with a cached call). // never cached (only job_output results pair with a cached call).
const callIds = new Map<string, Set<string>>() const callIds = new Map<string, Set<string>>()
if (typeof ctx.on !== 'function') { if (typeof ctx.on !== 'function') {
// Test doubles without the event API degrade to seed-only replay. // Test doubles without the event API degrade to seed-only replay.
@@ -165,9 +165,9 @@ function createTaskOutputMirror(ctx: Context): { entries(sessionId: string): rea
push(sessionId, trace) push(sessionId, trace)
} }
}) })
ctx.effect(() => dispose, 'dsh-better-sidebar: task-output event mirror') ctx.effect(() => dispose, 'dsh-better-sidebar: job-output event mirror')
const push = (sessionId: string, trace: TaskOutputTrace): void => { const push = (sessionId: string, trace: JobOutputTrace): void => {
let list = perSession.get(sessionId) let list = perSession.get(sessionId)
if (list === undefined) perSession.set(sessionId, list = []) if (list === undefined) perSession.set(sessionId, list = [])
list.push(trace) list.push(trace)
@@ -187,43 +187,43 @@ function createTaskOutputMirror(ctx: Context): { entries(sessionId: string): rea
} }
/** /**
* Build the tasks routes bound to the plugin context. `output` merges the * Build the jobs routes bound to the plugin context. `output` merges the
* owner session's own event log with the live task_output mirror; `kill` * owner session's own event log with the live job_output mirror; `kill`
* reads the tasks/agents services lazily and degrades to a 503 when the * reads the jobs/agents services lazily and degrades to a 503 when the
* deployment lacks the registry. * deployment lacks the registry.
* @param ctx - host plugin context. * @param ctx - host plugin context.
* @param outputLimit - response cap for one output replay in bytes; longer * @param outputLimit - response cap for one output replay in bytes; longer
* texts are sliced and flagged `truncated` (mirrors the fs.read cap). * texts are sliced and flagged `truncated` (mirrors the fs.read cap).
*/ */
export function buildTasksApi(ctx: Context, outputLimit: number): SidebarTasksRoutes { export function buildJobsApi(ctx: Context, outputLimit: number): SidebarJobsRoutes {
const tasks = ctx.get('tasks') const jobs = ctx.get('jobs')
const agents = ctx.get('agents') const agents = ctx.get('agents')
const mirror = createTaskOutputMirror(ctx) const mirror = createJobOutputMirror(ctx)
/** The live caller whose session id the registry fence compares against. */ /** The live caller whose session id the registry fence compares against. */
const callerOf = (sessionId: string) => agents?.get(sessionId) const callerOf = (sessionId: string) => agents?.get(sessionId)
/** Registry refusals become a 404 task-error; unknown and foreign ids are indistinguishable. */ /** Registry refusals become a 404 job-error; unknown and foreign ids are indistinguishable. */
const registryError = (error: unknown): SidebarError => const registryError = (error: unknown): SidebarError =>
new SidebarError('task-error', error instanceof Error ? error.message : String(error), 404) new SidebarError('job-error', error instanceof Error ? error.message : String(error), 404)
return { return {
output(payload) { output(payload) {
const sessionId = requireString(payload, 'sessionId') const sessionId = requireString(payload, 'sessionId')
const id = requireString(payload, 'id') const id = requireString(payload, 'id')
// Merge the store's event log (durable seed + whatever it received) // Merge the store's event log (durable seed + whatever it received)
// with the live mirror, deduped by seq — a trace never double-counts. // with the live mirror, deduped by seq — a trace never double-counts.
const bySeq = new Map<number, TaskOutputTrace>() const bySeq = new Map<number, JobOutputTrace>()
for (const event of ctx.sessions.get(sessionId)?.events ?? []) { for (const event of ctx.sessions.get(sessionId)?.events ?? []) {
const trace = traceOf(event) const trace = traceOf(event)
if (trace !== undefined) bySeq.set(trace.seq, trace) if (trace !== undefined) bySeq.set(trace.seq, trace)
} }
for (const trace of mirror.entries(sessionId)) bySeq.set(trace.seq, trace) for (const trace of mirror.entries(sessionId)) bySeq.set(trace.seq, trace)
// Pair calls with results in seq order: the model's reads, oldest first. // Pair calls with results in seq order: the model's reads, oldest first.
const taskOf = new Map<string, string>() const jobOf = new Map<string, string>()
const parts: string[] = [] const parts: string[] = []
let read = false let read = false
for (const trace of [...bySeq.values()].sort((left, right) => left.seq - right.seq)) { for (const trace of [...bySeq.values()].sort((left, right) => left.seq - right.seq)) {
if (trace.kind === 'call') { if (trace.kind === 'call') {
if (trace.taskId !== undefined) taskOf.set(trace.callId, trace.taskId) if (trace.jobId !== undefined) jobOf.set(trace.callId, trace.jobId)
} else if (taskOf.get(trace.callId) === id) { } else if (jobOf.get(trace.callId) === id) {
read = true read = true
if (trace.isError !== true && trace.text !== undefined && !isNoNewOutput(trace.text)) { if (trace.isError !== true && trace.text !== undefined && !isNoNewOutput(trace.text)) {
parts.push(trace.text) parts.push(trace.text)
@@ -238,8 +238,8 @@ export function buildTasksApi(ctx: Context, outputLimit: number): SidebarTasksRo
} }
}, },
kill(payload) { kill(payload) {
if (tasks === undefined) { if (jobs === undefined) {
throw new SidebarError('task-error', 'the background-task registry is not mounted in this deployment', 503) throw new SidebarError('job-error', 'the background-job registry is not mounted in this deployment', 503)
} }
const sessionId = requireString(payload, 'sessionId') const sessionId = requireString(payload, 'sessionId')
const id = requireString(payload, 'id') const id = requireString(payload, 'id')
@@ -248,7 +248,7 @@ export function buildTasksApi(ctx: Context, outputLimit: number): SidebarTasksRo
? record.reason ? record.reason
: 'user requested via sidebar' : 'user requested via sidebar'
try { try {
return { ok: true, outcome: tasks.kill(id, callerOf(sessionId), reason) } return { ok: true, outcome: jobs.kill(id, callerOf(sessionId), reason) }
} catch (error) { } catch (error) {
throw registryError(error) throw registryError(error)
} }
+4 -4
View File
@@ -22,10 +22,10 @@ export interface SidebarPrefs {
autoOpenSubagent: boolean autoOpenSubagent: boolean
/** /**
* Whether the sidebar auto-activates (opens the panel) and expands the * Whether the sidebar auto-activates (opens the panel) and expands the
* Tasks page when a NEW background task appears for the current * Jobs page when a NEW background job appears for the current
* conversation (any new task id, not just the first one). * conversation (any new job id, not just the first one).
*/ */
autoOpenTasks: boolean autoOpenJobs: boolean
/** /**
* Whether the model-facing agent terminal tools (terminal_create / list / * Whether the model-facing agent terminal tools (terminal_create / list /
* send / read / wait_for / resize / signal / close) are injected into the * send / read / wait_for / resize / signal / close) are injected into the
@@ -109,7 +109,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
openByDefault: true, openByDefault: true,
defaultWidthPercent: WIDTH_PERCENT_DEFAULT, defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
autoOpenSubagent: true, autoOpenSubagent: true,
autoOpenTasks: true, autoOpenJobs: true,
agentTerminalTools: false, agentTerminalTools: false,
bottomPanelAutoTerminal: true, bottomPanelAutoTerminal: true,
interceptOpenPath: true, interceptOpenPath: true,
+1 -1
View File
@@ -19,7 +19,7 @@ const TRANSCRIPT_LIMIT = 1 << 20
* Restore the executable bit pnpm strips from node-pty's prebuilt * Restore the executable bit pnpm strips from node-pty's prebuilt
* spawn-helper (the macOS helper that forks and sets up the pty). Without it * spawn-helper (the macOS helper that forks and sets up the pty). Without it
* every spawn fails with `posix_spawnp failed`. Idempotent; mirrors * every spawn fails with `posix_spawnp failed`. Idempotent; mirrors
* @deepseek-ai/dsh-pty-local's ensure-spawn-helper postinstall, run at * @deepseek-ai/dsh-terminal-bash's ensure-spawn-helper postinstall, run at
* plugin activation so link-installed deployments get the fix too. * plugin activation so link-installed deployments get the fix too.
*/ */
export function ensureSpawnHelper(): void { export function ensureSpawnHelper(): void {
+1 -1
View File
@@ -15,7 +15,7 @@ export type SidebarErrorCode =
| 'fs-error' | 'fs-error'
| 'git-error' | 'git-error'
| 'pty-error' | 'pty-error'
| 'task-error' | 'job-error'
| 'settings-rejected' | 'settings-rejected'
| 'settings-conflict' | 'settings-conflict'
| 'internal' | 'internal'
+1 -1
View File
@@ -44,7 +44,7 @@ describe('built-in tab registrations', () => {
it('the subagent tab declares its auto-open related settings', () => { it('the subagent tab declares its auto-open related settings', () => {
const { service } = setup() const { service } = setup()
const toggles = service.getTab('subagent')?.settings?.toggles ?? [] const toggles = service.getTab('subagent')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenTasks']) expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenJobs'])
}) })
it('the terminal tab declares the model terminal-tools related setting', () => { it('the terminal tab declares the model terminal-tools related setting', () => {
@@ -1,21 +1,21 @@
/** /**
* Host route tests for the background-task API ('tasks.output' / 'tasks.kill'). * Host route tests for the background-job API ('jobs.output' / 'jobs.kill').
* 'tasks.output' REPLAYS the output the model has read so far from the owner * 'jobs.output' REPLAYS the output the model has read so far from the owner
* session's event log (tool/call + tool/result pairs of task_output calls) — * session's event log (tool/call + tool/result pairs of job_output calls) —
* the model's cursor is never touched, so nothing is stolen and unread tasks * the model's cursor is never touched, so nothing is stolen and unread jobs
* report `read: false`. 'tasks.kill' uses the registry's stock kill, fenced * report `read: false`. 'jobs.kill' uses the registry's stock kill, fenced
* by the owning session, with a 503 when the registry is absent. * by the owning session, with a 503 when the registry is absent.
*/ */
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { buildTasksApi } from '../src/tasks-routes.ts' import { buildJobsApi } from '../src/jobs-routes.ts'
import { SidebarError } from '../src/wire.ts' import { SidebarError } from '../src/wire.ts'
import type { Context, SidebarSessionEvent } from '../src/context-types.ts' import type { Context, SidebarSessionEvent } from '../src/context-types.ts'
/** A context whose `get` serves only the tasks/agents faces, with a session store. */ /** A context whose `get` serves only the jobs/agents faces, with a session store. */
function ctxWith(sessions: unknown, tasks: unknown, agents: unknown): Context { function ctxWith(sessions: unknown, jobs: unknown, agents: unknown): Context {
return { return {
sessions, sessions,
get: (key: string) => (key === 'tasks' ? tasks : key === 'agents' ? agents : undefined), get: (key: string) => (key === 'jobs' ? jobs : key === 'agents' ? agents : undefined),
} as unknown as Context } as unknown as Context
} }
@@ -44,13 +44,13 @@ function ctxWithFeed(sessions: unknown): {
/** A stub live agent (the fence compares `id` only). */ /** A stub live agent (the fence compares `id` only). */
const agent = (id: string) => ({ id, session: { header: { cwd: '/p' } } }) const agent = (id: string) => ({ id, session: { header: { cwd: '/p' } } })
/** One task_output tool/call event. */ /** One job_output tool/call event. */
function taskOutputCall(seq: number, callId: string, taskId: string): SidebarSessionEvent { function jobOutputCall(seq: number, callId: string, jobId: string): SidebarSessionEvent {
return { type: 'tool/call', seq, time: seq, data: { name: 'task_output', callId, arguments: JSON.stringify({ task_id: taskId }) } } return { type: 'tool/call', seq, time: seq, data: { name: 'job_output', callId, arguments: JSON.stringify({ job_id: jobId }) } }
} }
/** One tool/result event carrying the finalized text the model received. */ /** One tool/result event carrying the finalized text the model received. */
function taskOutputResult(seq: number, callId: string, text: string, over: { isError?: boolean } = {}): SidebarSessionEvent { function jobOutputResult(seq: number, callId: string, text: string, over: { isError?: boolean } = {}): SidebarSessionEvent {
return { return {
type: 'tool/result', type: 'tool/result',
seq, seq,
@@ -74,18 +74,18 @@ function session(events: SidebarSessionEvent[]): unknown {
return { header: { cwd: '/p' }, events } return { header: { cwd: '/p' }, events }
} }
describe('tasks.output route (event replay)', () => { describe('jobs.output route (event replay)', () => {
it('concatenates the task_output results the model read for the task, oldest first', () => { it('concatenates the job_output results the model read for the job, oldest first', () => {
const events = [ const events = [
taskOutputCall(0, 'c1', 'bash-1'), jobOutputCall(0, 'c1', 'bash-1'),
taskOutputResult(1, 'c1', 'line1\nline2\n[status: running]'), jobOutputResult(1, 'c1', 'line1\nline2\n[status: running]'),
taskOutputCall(2, 'c2', 'bash-1'), jobOutputCall(2, 'c2', 'bash-1'),
taskOutputResult(3, 'c2', 'line3\n[status: completed, exit code: 0]'), jobOutputResult(3, 'c2', 'line3\n[status: completed, exit code: 0]'),
// Another task's reads are ignored. // Another job's reads are ignored.
taskOutputCall(4, 'c3', 'bash-2'), jobOutputCall(4, 'c3', 'bash-2'),
taskOutputResult(5, 'c3', 'other task output'), jobOutputResult(5, 'c3', 'other job output'),
] ]
const api = buildTasksApi(ctxWith({ get: () => session(events) }, undefined, undefined), 512 * 1024) const api = buildJobsApi(ctxWith({ get: () => session(events) }, undefined, undefined), 512 * 1024)
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
text: 'line1\nline2\n[status: running]\nline3\n[status: completed, exit code: 0]', text: 'line1\nline2\n[status: running]\nline3\n[status: completed, exit code: 0]',
truncated: false, truncated: false,
@@ -95,14 +95,14 @@ describe('tasks.output route (event replay)', () => {
it('skips the controller\'s "(no new output)" deltas and error results', () => { it('skips the controller\'s "(no new output)" deltas and error results', () => {
const events = [ const events = [
taskOutputCall(0, 'c1', 'bash-1'), jobOutputCall(0, 'c1', 'bash-1'),
taskOutputResult(1, 'c1', 'real output\n[status: running]'), jobOutputResult(1, 'c1', 'real output\n[status: running]'),
taskOutputCall(2, 'c2', 'bash-1'), jobOutputCall(2, 'c2', 'bash-1'),
taskOutputResult(3, 'c2', '(no new output)\n[status: running]'), jobOutputResult(3, 'c2', '(no new output)\n[status: running]'),
taskOutputCall(4, 'c3', 'bash-1'), jobOutputCall(4, 'c3', 'bash-1'),
taskOutputResult(5, 'c3', 'boom', { isError: true }), jobOutputResult(5, 'c3', 'boom', { isError: true }),
] ]
const api = buildTasksApi(ctxWith({ get: () => session(events) }, undefined, undefined), 512 * 1024) const api = buildJobsApi(ctxWith({ get: () => session(events) }, undefined, undefined), 512 * 1024)
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
text: 'real output\n[status: running]', text: 'real output\n[status: running]',
truncated: false, truncated: false,
@@ -110,36 +110,36 @@ describe('tasks.output route (event replay)', () => {
}) })
}) })
it('reports read:false until the model reads the task (no registry call at all)', () => { it('reports read:false until the model reads the job (no registry call at all)', () => {
const events = [taskOutputCall(0, 'c1', 'bash-2')] const events = [jobOutputCall(0, 'c1', 'bash-2')]
const tasks = { kill: vi.fn() } const jobs = { kill: vi.fn() }
const api = buildTasksApi(ctxWith({ get: () => session(events) }, tasks, undefined), 100) const api = buildJobsApi(ctxWith({ get: () => session(events) }, jobs, undefined), 100)
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false }) expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false })
// The replay never touches the registry — the model's cursor is safe by construction. // The replay never touches the registry — the model's cursor is safe by construction.
expect(tasks.kill).not.toHaveBeenCalled() expect(jobs.kill).not.toHaveBeenCalled()
}) })
it('mirrors live task_output events the store log misses (restart divergence)', () => { it('mirrors live job_output events the store log misses (restart divergence)', () => {
// The store session is frozen at its rehydration boundary (no events); // The store session is frozen at its rehydration boundary (no events);
// the read exists only on the live session/event feed. // the read exists only on the live session/event feed.
const { ctx, emit } = ctxWithFeed({ get: () => session([]) }) const { ctx, emit } = ctxWithFeed({ get: () => session([]) })
const api = buildTasksApi(ctx, 512 * 1024) const api = buildJobsApi(ctx, 512 * 1024)
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false }) expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false })
// The live feed delivers the task_output call and its result. // The live feed delivers the job_output call and its result.
emit({ id: 's1' }, taskOutputCall(100, 'c-live', 'bash-1')) emit({ id: 's1' }, jobOutputCall(100, 'c-live', 'bash-1'))
emit({ id: 's1' }, taskOutputResult(101, 'c-live', 'live line\n[status: completed, exit code: 0]')) emit({ id: 's1' }, jobOutputResult(101, 'c-live', 'live line\n[status: completed, exit code: 0]'))
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
text: 'live line\n[status: completed, exit code: 0]', text: 'live line\n[status: completed, exit code: 0]',
truncated: false, truncated: false,
read: true, read: true,
}) })
// Unrelated results are never cached (no task_output call paired them), // Unrelated results are never cached (no job_output call paired them),
// and another session's feed does not leak into this one. // and another session's feed does not leak into this one.
emit({ id: 's1' }, taskOutputResult(102, 'c-other', 'unpaired line')) emit({ id: 's1' }, jobOutputResult(102, 'c-other', 'unpaired line'))
emit({ id: 's2' }, taskOutputCall(103, 'c-s2', 'bash-1')) emit({ id: 's2' }, jobOutputCall(103, 'c-s2', 'bash-1'))
emit({ id: 's2' }, taskOutputResult(104, 'c-s2', 'other session line')) emit({ id: 's2' }, jobOutputResult(104, 'c-s2', 'other session line'))
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
text: 'live line\n[status: completed, exit code: 0]', text: 'live line\n[status: completed, exit code: 0]',
truncated: false, truncated: false,
@@ -156,11 +156,11 @@ describe('tasks.output route (event replay)', () => {
// A seed read (seq 5) in the store log plus a live read (seq 106) that // A seed read (seq 5) in the store log plus a live read (seq 106) that
// ALSO reached the store log would double-count — the seq dedupe keeps // ALSO reached the store log would double-count — the seq dedupe keeps
// exactly one copy of each. // exactly one copy of each.
const events = [taskOutputCall(5, 'c-seed', 'bash-1'), taskOutputResult(6, 'c-seed', 'seed line')] const events = [jobOutputCall(5, 'c-seed', 'bash-1'), jobOutputResult(6, 'c-seed', 'seed line')]
const { ctx, emit } = ctxWithFeed({ get: () => session(events) }) const { ctx, emit } = ctxWithFeed({ get: () => session(events) })
const api = buildTasksApi(ctx, 512 * 1024) const api = buildJobsApi(ctx, 512 * 1024)
emit({ id: 's1' }, taskOutputCall(106, 'c-live', 'bash-1')) emit({ id: 's1' }, jobOutputCall(106, 'c-live', 'bash-1'))
emit({ id: 's1' }, taskOutputResult(107, 'c-live', 'live line')) emit({ id: 's1' }, jobOutputResult(107, 'c-live', 'live line'))
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
text: 'seed line\nlive line', text: 'seed line\nlive line',
truncated: false, truncated: false,
@@ -169,8 +169,8 @@ describe('tasks.output route (event replay)', () => {
}) })
it('caps oversized replays with the truncated flag', () => { it('caps oversized replays with the truncated flag', () => {
const events = [taskOutputCall(0, 'c1', 'bash-1'), taskOutputResult(1, 'c1', 'x'.repeat(10_000))] const events = [jobOutputCall(0, 'c1', 'bash-1'), jobOutputResult(1, 'c1', 'x'.repeat(10_000))]
const api = buildTasksApi(ctxWith({ get: () => session(events) }, undefined, undefined), 100) const api = buildJobsApi(ctxWith({ get: () => session(events) }, undefined, undefined), 100)
const value = api.output({ sessionId: 's1', id: 'bash-1' }) const value = api.output({ sessionId: 's1', id: 'bash-1' })
expect(value.text).toBe('x'.repeat(100)) expect(value.text).toBe('x'.repeat(100))
expect(value.truncated).toBe(true) expect(value.truncated).toBe(true)
@@ -178,41 +178,41 @@ describe('tasks.output route (event replay)', () => {
}) })
it('rejects a missing sessionId or id as bad-request', () => { it('rejects a missing sessionId or id as bad-request', () => {
const api = buildTasksApi(ctxWith({ get: () => undefined }, undefined, undefined), 100) const api = buildJobsApi(ctxWith({ get: () => undefined }, undefined, undefined), 100)
expect(() => api.output({ id: 'bash-1' })).toThrowError(expect.objectContaining<Partial<SidebarError>>({ code: 'bad-request' })) expect(() => api.output({ id: 'bash-1' })).toThrowError(expect.objectContaining<Partial<SidebarError>>({ code: 'bad-request' }))
expect(() => api.output({ sessionId: 's1' })).toThrowError(expect.objectContaining<Partial<SidebarError>>({ code: 'bad-request' })) expect(() => api.output({ sessionId: 's1' })).toThrowError(expect.objectContaining<Partial<SidebarError>>({ code: 'bad-request' }))
}) })
}) })
describe('tasks.kill route', () => { describe('jobs.kill route', () => {
it('kills with the forwarded reason and the live caller', () => { it('kills with the forwarded reason and the live caller', () => {
const tasks = { kill: vi.fn(() => 'requested' as const) } const jobs = { kill: vi.fn(() => 'requested' as const) }
const agents = { get: vi.fn((id: string) => agent(id)) } const agents = { get: vi.fn((id: string) => agent(id)) }
const api = buildTasksApi(ctxWith({ get: () => undefined }, tasks, agents), 100) const api = buildJobsApi(ctxWith({ get: () => undefined }, jobs, agents), 100)
expect(api.kill({ sessionId: 's1', id: 'bash-1', reason: 'user pressed stop' })) expect(api.kill({ sessionId: 's1', id: 'bash-1', reason: 'user pressed stop' }))
.toEqual({ ok: true, outcome: 'requested' }) .toEqual({ ok: true, outcome: 'requested' })
expect(tasks.kill).toHaveBeenCalledWith('bash-1', agent('s1'), 'user pressed stop') expect(jobs.kill).toHaveBeenCalledWith('bash-1', agent('s1'), 'user pressed stop')
}) })
it('defaults the reason when none is supplied', () => { it('defaults the reason when none is supplied', () => {
const tasks = { kill: vi.fn(() => 'already-finished' as const) } const jobs = { kill: vi.fn(() => 'already-finished' as const) }
const api = buildTasksApi(ctxWith({ get: () => undefined }, tasks, undefined), 100) const api = buildJobsApi(ctxWith({ get: () => undefined }, jobs, undefined), 100)
expect(api.kill({ sessionId: 's1', id: 'bash-1' })).toEqual({ ok: true, outcome: 'already-finished' }) expect(api.kill({ sessionId: 's1', id: 'bash-1' })).toEqual({ ok: true, outcome: 'already-finished' })
expect(tasks.kill).toHaveBeenCalledWith('bash-1', undefined, 'user requested via sidebar') expect(jobs.kill).toHaveBeenCalledWith('bash-1', undefined, 'user requested via sidebar')
}) })
it('maps registry refusals to a 404 task-error', () => { it('maps registry refusals to a 404 job-error', () => {
const tasks = { kill: vi.fn(() => { throw new Error('unknown task bash-9') }) } const jobs = { kill: vi.fn(() => { throw new Error('unknown job bash-9') }) }
const api = buildTasksApi(ctxWith({ get: () => undefined }, tasks, undefined), 100) const api = buildJobsApi(ctxWith({ get: () => undefined }, jobs, undefined), 100)
expect(() => api.kill({ sessionId: 's1', id: 'bash-9' })).toThrowError( expect(() => api.kill({ sessionId: 's1', id: 'bash-9' })).toThrowError(
expect.objectContaining<Partial<SidebarError>>({ code: 'task-error', status: 404 }), expect.objectContaining<Partial<SidebarError>>({ code: 'job-error', status: 404 }),
) )
}) })
it('degrades to a 503 when the tasks registry is absent (output keeps working)', () => { it('degrades to a 503 when the jobs registry is absent (output keeps working)', () => {
const api = buildTasksApi(ctxWith({ get: () => session([]) }, undefined, undefined), 100) const api = buildJobsApi(ctxWith({ get: () => session([]) }, undefined, undefined), 100)
expect(() => api.kill({ sessionId: 's1', id: 'bash-1' })).toThrowError( expect(() => api.kill({ sessionId: 's1', id: 'bash-1' })).toThrowError(
expect.objectContaining<Partial<SidebarError>>({ code: 'task-error', status: 503 }), expect.objectContaining<Partial<SidebarError>>({ code: 'job-error', status: 503 }),
) )
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false }) expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false })
}) })
+109
View File
@@ -0,0 +1,109 @@
/**
* Locale-following spec: the sidebar copy follows the DSH locale service
* (`ctx.locale`, provided by @deepseek-ai/dsh-client-locale) when attached
* through `attachLocale`, and falls back to the browser language otherwise.
* Covers attach/detach, live switching, dictionary parity, and placeholder
* interpolation.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { LOCALE_NS, attachLocale, en, isZh, relativeTime, t, zh } from '../src/client/locales.ts'
/** Minimal structural fake of the DSH LocaleService face the sidebar uses. */
class FakeLocale {
active: string = 'en'
getSnapshot(): { active: string } {
return { active: this.active }
}
subscribe(_fn: () => void): () => void {
return () => {}
}
register(_ns: string, _locale: string, _dict: Record<string, string>): () => void {
return () => {}
}
switchTo(id: string): void {
this.active = id
}
}
/** Point the browser-language fallback at a specific language (undefined = none). */
function stubNavigatorLanguage(lang: string | undefined): void {
Object.defineProperty(globalThis, 'navigator', {
value: lang === undefined ? undefined : { language: lang },
configurable: true,
})
}
afterEach(() => {
attachLocale(undefined)
stubNavigatorLanguage(undefined)
})
describe('locales (DSH i18n following)', () => {
it('falls back to the browser language without an attached service', () => {
stubNavigatorLanguage('en-US')
expect(t('explorer')).toBe('Explorer')
expect(isZh()).toBe(false)
stubNavigatorLanguage('zh-CN')
expect(t('explorer')).toBe('资源管理器')
expect(isZh()).toBe(true)
})
it('defaults to English when no locale service and no browser language are available', () => {
expect(t('explorer')).toBe('Explorer')
expect(isZh()).toBe(false)
})
it('follows the attached locale service instead of the browser language', () => {
stubNavigatorLanguage('en-US')
const locale = new FakeLocale()
attachLocale(locale)
locale.switchTo('zh')
expect(t('git')).toBe('源代码管理')
expect(isZh()).toBe(true)
// Live switch: the service's active locale wins even though the
// browser still asks for en-US.
locale.switchTo('en')
expect(t('git')).toBe('Source Control')
expect(isZh()).toBe(false)
})
it('detaches back to the browser-language fallback', () => {
const locale = new FakeLocale()
locale.switchTo('zh')
attachLocale(locale)
expect(t('terminal')).toBe('终端')
stubNavigatorLanguage('en-US')
attachLocale(undefined)
expect(t('terminal')).toBe('Terminal')
})
it('interpolates {name} placeholders in the active locale', () => {
const locale = new FakeLocale()
attachLocale(locale)
locale.switchTo('zh')
expect(t('timeMinutesAgo', { n: 5 })).toBe('5 分钟前')
locale.switchTo('en')
expect(t('timeMinutesAgo', { n: 5 })).toBe('5 min ago')
})
it('drives the relative-time chain through the active locale', () => {
const locale = new FakeLocale()
attachLocale(locale)
locale.switchTo('zh')
expect(relativeTime(new Date().toISOString())).toBe('刚刚')
locale.switchTo('en')
expect(relativeTime(new Date().toISOString())).toBe('just now')
})
it('registers a namespace distinct from DSH ui-sidebar\'s own \'sidebar\'', () => {
expect(LOCALE_NS).toBe('betterSidebar')
})
it('keeps the zh and en dictionaries key-set-equal', () => {
expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort())
})
})
+4 -4
View File
@@ -17,7 +17,7 @@ describe('dsh-better-sidebar plugin export shape', () => {
const unwrapped = loader.unwrapExports(sidebar) as Record<string, unknown> const unwrapped = loader.unwrapExports(sidebar) as Record<string, unknown>
expect(unwrapped).toBe(sidebar) expect(unwrapped).toBe(sidebar)
expect(unwrapped.name).toBe('dsh-better-sidebar') expect(unwrapped.name).toBe('dsh-better-sidebar')
expect(unwrapped.inject).toEqual(['httpServer', 'sessions', 'loader', 'tools']) expect(unwrapped.inject).toEqual(['webServer', 'sessions', 'loader', 'tools'])
expect(unwrapped.Config).toBeDefined() expect(unwrapped.Config).toBeDefined()
expect(typeof unwrapped.apply).toBe('function') expect(typeof unwrapped.apply).toBe('function')
}) })
@@ -45,8 +45,8 @@ describe('dsh-better-sidebar plugin export shape', () => {
expect(resolved.openByDefault).toBe(true) expect(resolved.openByDefault).toBe(true)
expect(resolved.defaultWidthPercent).toBe(30) expect(resolved.defaultWidthPercent).toBe(30)
expect(resolved.autoOpenSubagent).toBe(true) expect(resolved.autoOpenSubagent).toBe(true)
// A new background task auto-opens the Tasks page too. // A new background job auto-opens the Jobs page too.
expect(resolved.autoOpenTasks).toBe(true) expect(resolved.autoOpenJobs).toBe(true)
// The terminal tools default OFF (the feature is dormant until the user // The terminal tools default OFF (the feature is dormant until the user
// enables it in the side card settings). // enables it in the side card settings).
expect(resolved.agentTerminalTools).toBe(false) expect(resolved.agentTerminalTools).toBe(false)
@@ -59,6 +59,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
const overridden = (PrefsSchema as unknown as { const overridden = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown> (input: Record<string, unknown> | undefined): Record<string, unknown>
})({ openByDefault: false, defaultWidthPercent: 45 }) })({ openByDefault: false, defaultWidthPercent: 45 })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenTasks: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
}) })
}) })
+94
View File
@@ -0,0 +1,94 @@
/**
* Pure payload logic for the viewer selection popup ("add to
* conversation"): the fence header (relative path + line span), the
* over-limit plain-line fallback, and the best-effort reverse-search that
* maps a preview selection back to source lines.
*/
import { describe, expect, it } from 'vitest'
import {
SELECTION_LIMIT,
buildSelectionInsert,
headerOf,
linesOfSelection,
} from '../src/client/selection-payload.ts'
describe('headerOf', () => {
it('projects the path relative to the session cwd', () => {
expect(headerOf('/p/docs/plan.md', '/p', { start: 12, end: 12 })).toBe('docs/plan.md:12')
})
it('falls back to the absolute path when the cwd is unknown', () => {
expect(headerOf('/p/docs/plan.md', undefined, { start: 3, end: 3 })).toBe('/p/docs/plan.md:3')
})
it('keeps the absolute path for files outside the cwd', () => {
expect(headerOf('/other/x.md', '/p', { start: 1, end: 1 })).toBe('/other/x.md:1')
})
it('writes a single line number for single-line selections', () => {
expect(headerOf('/p/a.md', '/p', { start: 5, end: 5 })).toBe('a.md:5')
})
it('writes the start-end range for multi-line selections', () => {
expect(headerOf('/p/a.md', '/p', { start: 5, end: 9 })).toBe('a.md:5-9')
})
it('omits the line suffix entirely when the lines are unknown', () => {
expect(headerOf('/p/a.md', '/p', undefined)).toBe('a.md')
})
})
describe('buildSelectionInsert', () => {
it('wraps a small selection in a fenced block with the path:line info line', () => {
expect(buildSelectionInsert('/p/a.ts', '/p', { start: 2, end: 4 }, 'const x = 1')).toBe(
'```a.ts:2-4\nconst x = 1\n```',
)
})
it('accepts a selection exactly at the limit (fenced)', () => {
const text = 'x'.repeat(SELECTION_LIMIT)
expect(buildSelectionInsert('/p/a.ts', '/p', { start: 1, end: 1 }, text)).toBe(
`\`\`\`a.ts:1\n${text}\n\`\`\``,
)
})
it('drops the content past the limit: one plain path line, no fence', () => {
const text = 'x'.repeat(SELECTION_LIMIT + 1)
expect(buildSelectionInsert('/p/a.ts', '/p', { start: 7, end: 7 }, text)).toBe('a.ts:7')
})
it('keeps the plain-line form when past the limit without line numbers', () => {
const text = 'x'.repeat(SELECTION_LIMIT + 1)
expect(buildSelectionInsert('/p/a.ts', '/p', undefined, text)).toBe('a.ts')
})
})
describe('linesOfSelection', () => {
const source = 'alpha\nbeta\ngamma alpha\ndelta\n'
it('maps a unique hit to its source line span', () => {
expect(linesOfSelection(source, 'beta')).toEqual({ start: 2, end: 2 })
expect(linesOfSelection(source, 'gamma alpha')).toEqual({ start: 3, end: 3 })
})
it('spans the end line when the selection crosses line breaks', () => {
expect(linesOfSelection(source, 'beta\ngamma')).toEqual({ start: 2, end: 3 })
})
it('returns null when the text is missing from the source', () => {
expect(linesOfSelection(source, 'nope')).toBeNull()
})
it('returns null on an ambiguous (multi-hit) match', () => {
expect(linesOfSelection(source, 'alpha')).toBeNull()
})
it('strips a single trailing newline before searching (DOM block selections)', () => {
expect(linesOfSelection(source, 'delta\n')).toEqual({ start: 4, end: 4 })
})
it('returns null for an empty selection', () => {
expect(linesOfSelection(source, '')).toBeNull()
expect(linesOfSelection(source, '\n')).toBeNull()
})
})
+11 -11
View File
@@ -17,7 +17,7 @@ import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-typ
interface FakeContext { interface FakeContext {
loader: { entries: () => never[] } loader: { entries: () => never[] }
httpServer: { webServer: {
register: (route: SidebarWebRoute) => () => void register: (route: SidebarWebRoute) => () => void
registerUpgrade: (route: SidebarWebUpgradeRoute) => () => void registerUpgrade: (route: SidebarWebUpgradeRoute) => () => void
} }
@@ -27,7 +27,7 @@ interface FakeContext {
/** The settings service never appears in the smoke context: the inject /** The settings service never appears in the smoke context: the inject
* callback must never run (mirror of cordis' service-less inject). */ * callback must never run (mirror of cordis' service-less inject). */
inject: (deps: readonly string[], callback: (sctx: never) => void) => () => void inject: (deps: readonly string[], callback: (sctx: never) => void) => () => void
/** Optional services (tasks/agents) are read lazily; absent → undefined. */ /** Optional services (jobs/agents) are read lazily; absent → undefined. */
get: (key: string) => undefined get: (key: string) => undefined
} }
@@ -43,7 +43,7 @@ describe('host plugin smoke', () => {
const effects: Array<() => void | (() => void)> = [] const effects: Array<() => void | (() => void)> = []
const ctx: FakeContext = { const ctx: FakeContext = {
loader: { entries: () => [] }, loader: { entries: () => [] },
httpServer: { webServer: {
register: (route) => { routes.push(route); return () => {} }, register: (route) => { routes.push(route); return () => {} },
registerUpgrade: (route) => { upgrades.push(route); return () => {} }, registerUpgrade: (route) => { upgrades.push(route); return () => {} },
}, },
@@ -58,7 +58,7 @@ describe('host plugin smoke', () => {
// No settings service in the smoke context: the registration callback // No settings service in the smoke context: the registration callback
// never runs (cordis' service-less inject behaves the same). // never runs (cordis' service-less inject behaves the same).
inject: () => () => {}, inject: () => () => {},
// No tasks/agents services: the tasks routes degrade to a 503. // No jobs/agents services: the jobs routes degrade to a 503.
get: () => undefined, get: () => undefined,
} }
apply(ctx as never) apply(ctx as never)
@@ -309,7 +309,7 @@ describe('session cwd resolution over the API route', () => {
const routes: SidebarWebRoute[] = [] const routes: SidebarWebRoute[] = []
const ctx = { const ctx = {
loader: { entries: () => [] }, loader: { entries: () => [] },
httpServer: { webServer: {
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} }, register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} }, registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
}, },
@@ -319,7 +319,7 @@ describe('session cwd resolution over the API route', () => {
effect: (fn: () => void | (() => void)) => { fn() }, effect: (fn: () => void | (() => void)) => { fn() },
// No settings service: the namespace registration never runs. // No settings service: the namespace registration never runs.
inject: () => () => {}, inject: () => () => {},
// No tasks/agents services in the smoke context: the routes degrade. // No jobs/agents services in the smoke context: the routes degrade.
get: () => undefined, get: () => undefined,
} }
apply(ctx as never) apply(ctx as never)
@@ -459,7 +459,7 @@ describe('side card settings routes', () => {
const routes: SidebarWebRoute[] = [] const routes: SidebarWebRoute[] = []
const ctx = { const ctx = {
loader: { entries: () => [] }, loader: { entries: () => [] },
httpServer: { webServer: {
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} }, register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} }, registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
}, },
@@ -470,7 +470,7 @@ describe('side card settings routes', () => {
if (deps.includes('settings') && settings !== undefined) callback({ settings }) if (deps.includes('settings') && settings !== undefined) callback({ settings })
return () => {} return () => {}
}, },
// No tasks/agents services: the tasks routes degrade to a 503. // No jobs/agents services: the jobs routes degrade to a 503.
get: () => undefined, get: () => undefined,
} }
apply(ctx as never) apply(ctx as never)
@@ -514,7 +514,7 @@ describe('side card settings routes', () => {
openByDefault: true, openByDefault: true,
defaultWidthPercent: 30, defaultWidthPercent: 30,
autoOpenSubagent: true, autoOpenSubagent: true,
autoOpenTasks: true, autoOpenJobs: true,
agentTerminalTools: false, agentTerminalTools: false,
bottomPanelAutoTerminal: true, bottomPanelAutoTerminal: true,
interceptOpenPath: true, interceptOpenPath: true,
@@ -645,7 +645,7 @@ describe('agent terminal tool gating', () => {
} }
const ctx = { const ctx = {
loader: { entries: () => [] }, loader: { entries: () => [] },
httpServer: { webServer: {
register: (route: SidebarWebRoute) => { void route; return () => {} }, register: (route: SidebarWebRoute) => { void route; return () => {} },
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} }, registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
}, },
@@ -656,7 +656,7 @@ describe('agent terminal tool gating', () => {
if (deps.includes('settings')) callback({ settings }) if (deps.includes('settings')) callback({ settings })
return () => {} return () => {}
}, },
// No tasks/agents services: the tasks routes degrade to a 503. // No jobs/agents services: the jobs routes degrade to a 503.
get: () => undefined, get: () => undefined,
} }
apply(ctx as never) apply(ctx as never)
@@ -1,7 +1,7 @@
/** /**
* Subagent page tests for the background-task section: rows render from the * Subagent page tests for the background-job section: rows render from the
* `tasksBySession` mirror, clicking a row peeks its output through * `jobsBySession` mirror, clicking a row peeks its output through
* `tasks.output` with the OWNER session scope, the kill button needs a * `jobs.output` with the OWNER session scope, the kill button needs a
* two-click confirm, settled rows offer no kill, and the output panel never * two-click confirm, settled rows offer no kill, and the output panel never
* polls while the page is hidden. * polls while the page is hidden.
*/ */
@@ -80,7 +80,7 @@ function baseSnapshot(): SidebarSessionList {
child: { id: 'child', displayTitle: '子代理', origin: 'subagent', parentId: 'root', running: true }, child: { id: 'child', displayTitle: '子代理', origin: 'subagent', parentId: 'root', running: true },
}, },
subagentsByParent: {}, subagentsByParent: {},
tasksBySession: { jobsBySession: {
root: [ root: [
{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }, { id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 },
], ],
@@ -97,9 +97,9 @@ beforeEach(() => {
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => { vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
const method = String(url).split('/').pop() const method = String(url).split('/').pop()
const body = JSON.parse(String(init?.body)) as { sessionId: string; id: string } const body = JSON.parse(String(init?.body)) as { sessionId: string; id: string }
if (method === 'tasks.output') { if (method === 'jobs.output') {
outputCalls.push({ sessionId: body.sessionId, id: body.id }) outputCalls.push({ sessionId: body.sessionId, id: body.id })
// bash-9 stands for a task the model never read (read:false). // bash-9 stands for a job the model never read (read:false).
return jsonResponse({ return jsonResponse({
ok: true, ok: true,
value: { value: {
@@ -109,7 +109,7 @@ beforeEach(() => {
}, },
}) })
} }
if (method === 'tasks.kill') { if (method === 'jobs.kill') {
killCalls.push({ sessionId: body.sessionId, id: body.id }) killCalls.push({ sessionId: body.sessionId, id: body.id })
return jsonResponse({ ok: true, value: { ok: true, outcome: 'requested' } }) return jsonResponse({ ok: true, value: { ok: true, outcome: 'requested' } })
} }
@@ -123,15 +123,15 @@ afterEach(() => {
for (const el of document.querySelectorAll('body > div')) el.remove() for (const el of document.querySelectorAll('body > div')) el.remove()
}) })
describe('SubagentView background tasks', () => { describe('SubagentView background jobs', () => {
it('renders the tree tasks with status, durations, and owner labels', () => { it('renders the tree jobs with status, durations, and owner labels', () => {
const store = makeStore(baseSnapshot()) const store = makeStore(baseSnapshot())
const { container, unmount } = mount( const { container, unmount } = mount(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
) )
const text = container.textContent ?? '' const text = container.textContent ?? ''
expect(text).toContain('后台命令') expect(text).toContain('后台任务')
expect(text).toContain('2 个后台命令 · 1 运行中') expect(text).toContain('2 个后台任务 · 1 运行中')
// Both rows of the whole tree, owner-labeled (the tree spans two sessions). // Both rows of the whole tree, owner-labeled (the tree spans two sessions).
expect(text).toContain('sleep 300') expect(text).toContain('sleep 300')
expect(text).toContain('echo hi') expect(text).toContain('echo hi')
@@ -142,12 +142,12 @@ describe('SubagentView background tasks', () => {
unmount() unmount()
}) })
it('renders nothing task-related when the mirror is empty', () => { it('renders nothing job-related when the mirror is empty', () => {
const store = makeStore({ current: 'root', byId: { root: { id: 'root', displayTitle: '主会话' } }, subagentsByParent: {}, tasksBySession: {} }) const store = makeStore({ current: 'root', byId: { root: { id: 'root', displayTitle: '主会话' } }, subagentsByParent: {}, jobsBySession: {} })
const { container, unmount } = mount( const { container, unmount } = mount(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
) )
expect(container.textContent).not.toContain('后台命令') expect(container.textContent).not.toContain('后台任务')
unmount() unmount()
}) })
@@ -157,19 +157,19 @@ describe('SubagentView background tasks', () => {
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
) )
expect(container.textContent).toContain('sleep 300') expect(container.textContent).toContain('sleep 300')
// The mirror empties (all tasks settled and dropped): the section must // The mirror empties (all jobs settled and dropped): the section must
// vanish WITHOUT reordering hooks — a hook below the empty-state return // vanish WITHOUT reordering hooks — a hook below the empty-state return
// would crash React with "Rendered fewer hooks than expected" (the // would crash React with "Rendered fewer hooks than expected" (the
// minified #300 the sidebar boundary surfaces with a retry button). // minified #300 the sidebar boundary surfaces with a retry button).
await act(async () => { store.set({ ...baseSnapshot(), tasksBySession: {} }) }) await act(async () => { store.set({ ...baseSnapshot(), jobsBySession: {} }) })
expect(container.textContent).not.toContain('后台命令') expect(container.textContent).not.toContain('后台任务')
// And returning tasks must work too, with the same hook order. // And returning jobs must work too, with the same hook order.
await act(async () => { store.set(baseSnapshot()) }) await act(async () => { store.set(baseSnapshot()) })
expect(container.textContent).toContain('sleep 300') expect(container.textContent).toContain('sleep 300')
unmount() unmount()
}) })
it('shows the selected task output in the bottom dock, closeable', async () => { it('shows the selected job output in the bottom dock, closeable', async () => {
const store = makeStore(baseSnapshot()) const store = makeStore(baseSnapshot())
const { container, unmount } = mount( const { container, unmount } = mount(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
@@ -200,7 +200,7 @@ describe('SubagentView background tasks', () => {
expect(container.textContent).toContain('output-of-bash-1') expect(container.textContent).toContain('output-of-bash-1')
const second = container.querySelector('button[aria-label*="echo hi"]') as HTMLButtonElement const second = container.querySelector('button[aria-label*="echo hi"]') as HTMLButtonElement
await act(async () => { second.click() }) await act(async () => { second.click() })
// One dock, now fed by the second task (its owner session scopes the replay). // One dock, now fed by the second job (its owner session scopes the replay).
expect(outputCalls).toEqual([ expect(outputCalls).toEqual([
{ sessionId: 'root', id: 'bash-1' }, { sessionId: 'root', id: 'bash-1' },
{ sessionId: 'child', id: 'bash-2' }, { sessionId: 'child', id: 'bash-2' },
@@ -211,11 +211,11 @@ describe('SubagentView background tasks', () => {
unmount() unmount()
}) })
it('explains when the model has not read the task yet', async () => { it('explains when the model has not read the job yet', async () => {
const snapshot = baseSnapshot() const snapshot = baseSnapshot()
snapshot.tasksBySession = { snapshot.jobsBySession = {
root: [ root: [
...(snapshot.tasksBySession?.root ?? []), ...(snapshot.jobsBySession?.root ?? []),
{ id: 'bash-9', kind: 'bash', label: 'unread cmd', status: 'running', startedAt: 9_000 }, { id: 'bash-9', kind: 'bash', label: 'unread cmd', status: 'running', startedAt: 9_000 },
], ],
} }
@@ -225,13 +225,13 @@ describe('SubagentView background tasks', () => {
) )
const row = container.querySelector('button[aria-label*="unread cmd"]') as HTMLButtonElement const row = container.querySelector('button[aria-label*="unread cmd"]') as HTMLButtonElement
await act(async () => { row.click() }) await act(async () => { row.click() })
// read:false → the pane explains the output awaits the model's task_output // read:false → the pane explains the output awaits the model's job_output
// (never the model's cursor, so there is nothing to steal yet). // (never the model's cursor, so there is nothing to steal yet).
expect(container.textContent).toContain('等待模型读取该任务的输出') expect(container.textContent).toContain('等待模型读取该任务的输出')
unmount() unmount()
}) })
it('stays compact and functional with many tasks', async () => { it('stays compact and functional with many jobs', async () => {
const many = Array.from({ length: 60 }, (_, index) => ({ const many = Array.from({ length: 60 }, (_, index) => ({
id: `bash-${index + 10}`, id: `bash-${index + 10}`,
kind: 'bash' as const, kind: 'bash' as const,
@@ -244,12 +244,12 @@ describe('SubagentView background tasks', () => {
current: 'root', current: 'root',
byId: { root: { id: 'root', displayTitle: '主会话' } }, byId: { root: { id: 'root', displayTitle: '主会话' } },
subagentsByParent: {}, subagentsByParent: {},
tasksBySession: { root: many }, jobsBySession: { root: many },
}) })
const { container, unmount } = mount( const { container, unmount } = mount(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
) )
expect(container.textContent).toContain('60 个后台命令 · 30 运行中') expect(container.textContent).toContain('60 个后台任务 · 30 运行中')
expect(container.querySelectorAll('button[aria-label*="bulk cmd"]')).toHaveLength(60) expect(container.querySelectorAll('button[aria-label*="bulk cmd"]')).toHaveLength(60)
// Clicking a row anywhere in the long list still feeds the single dock. // Clicking a row anywhere in the long list still feeds the single dock.
const row = container.querySelector('button[aria-label*="bulk cmd 59"]') as HTMLButtonElement const row = container.querySelector('button[aria-label*="bulk cmd 59"]') as HTMLButtonElement
@@ -259,7 +259,7 @@ describe('SubagentView background tasks', () => {
unmount() unmount()
}) })
it('kills a live task only after the two-click confirm', async () => { it('kills a live job only after the two-click confirm', async () => {
const store = makeStore(baseSnapshot()) const store = makeStore(baseSnapshot())
const { container, unmount } = mount( const { container, unmount } = mount(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
+165
View File
@@ -0,0 +1,165 @@
/**
* Pure-helper tests for the Subagent page's background-job section:
* tree-membership collection, ordering, and status presentation mapping.
*/
import { describe, expect, it } from 'vitest'
import {
collectTreeJobs,
detectNewJob,
formatJobDuration,
isJobLive,
orderJobs,
jobDotState,
jobStatusLabel,
treeSessionIds,
} from '../src/client/subagent-jobs.ts'
import type { SidebarSessionList, SidebarSessionSummary, SidebarJobStatus, SidebarJobView } from '../src/context-types.ts'
/** The translator stub: renders duration templates like the real locale copy. */
const templates: Record<string, string> = {
jobDurationSeconds: '{seconds}秒',
jobDurationMinutes: '{minutes}分{seconds}秒',
jobDurationHours: '{hours}小时{minutes}分',
}
const t = (key: string, params?: Record<string, string | number>): string => {
let text = templates[key] ?? key
if (params !== undefined) {
for (const [name, value] of Object.entries(params)) text = text.replaceAll(`{${name}}`, String(value))
}
return text
}
function summary(id: string, over: Partial<SidebarSessionSummary> = {}): SidebarSessionSummary {
return { id, displayTitle: `title-${id}`, ...over }
}
function job(id: string, over: Partial<SidebarJobView> = {}): SidebarJobView {
return { id, kind: 'bash', label: `cmd ${id}`, status: 'running', startedAt: 1_000, ...over }
}
describe('treeSessionIds', () => {
it('includes the root and every subagent-origin session whose chain reaches it', () => {
const byId = {
root: summary('root'),
child: summary('child', { origin: 'subagent', parentId: 'root' }),
grand: summary('grand', { origin: 'subagent', parentId: 'child' }),
orphan: summary('orphan', { origin: 'subagent', parentId: 'gone' }),
other: summary('other', { origin: 'subagent', parentId: 'other-root' }),
}
const ids = treeSessionIds(byId, 'root')
expect([...ids].sort()).toEqual(['child', 'grand', 'root'])
})
it('fails soft on parent cycles and yields nothing without a root', () => {
const byId = {
root: summary('root'),
a: summary('a', { origin: 'subagent', parentId: 'b' }),
b: summary('b', { origin: 'subagent', parentId: 'a' }),
}
expect(treeSessionIds(byId, 'root').size).toBe(1)
expect(treeSessionIds(byId, undefined).size).toBe(0)
})
})
describe('collectTreeJobs', () => {
it('collects jobs of the whole tree with owner titles, ignoring outside sessions', () => {
const byId = {
root: summary('root'),
child: summary('child', { origin: 'subagent', parentId: 'root' }),
}
const jobsBySession = {
root: [job('bash-1')],
child: [job('bash-2', { status: 'completed', finishedAt: 2_000 })],
stranger: [job('bash-9')],
}
const rows = collectTreeJobs(byId, jobsBySession, 'root')
expect(rows.map(row => [row.ownerSessionId, row.ownerTitle, row.job.id]))
.toEqual([['root', 'title-root', 'bash-1'], ['child', 'title-child', 'bash-2']])
})
it('returns an empty list for an absent mirror or empty sets', () => {
const byId = { root: summary('root') }
expect(collectTreeJobs(byId, undefined, 'root')).toEqual([])
expect(collectTreeJobs(byId, {}, 'root')).toEqual([])
})
})
describe('orderJobs', () => {
it('puts live rows first in start order, then settled rows newest-first', () => {
const row = (id: string, status: SidebarJobStatus, startedAt: number, finishedAt?: number) => ({
ownerSessionId: 'root',
ownerTitle: 'root',
job: job(id, { status, startedAt, ...(finishedAt !== undefined ? { finishedAt } : {}) }),
})
const rows = [
row('old-settled', 'completed', 1_000, 2_000),
row('live-2', 'running', 4_000),
row('new-settled', 'killed', 1_500, 1_800),
row('live-1', 'stopping', 3_000),
]
expect(orderJobs(rows).map(r => r.job.id)).toEqual(['live-1', 'live-2', 'old-settled', 'new-settled'])
})
})
describe('status presentation helpers', () => {
it('treats running and stopping as live', () => {
expect(isJobLive(job('a', { status: 'running' }))).toBe(true)
expect(isJobLive(job('b', { status: 'stopping' }))).toBe(true)
expect(isJobLive(job('c', { status: 'completed' }))).toBe(false)
expect(isJobLive(job('d', { status: 'killed' }))).toBe(false)
expect(isJobLive(job('e', { status: 'failed' }))).toBe(false)
})
it('maps the five wire statuses to dot states and localized labels', () => {
expect(jobDotState('running')).toBe('ongoing')
expect(jobDotState('stopping')).toBe('warning')
expect(jobDotState('completed')).toBe('done')
expect(jobDotState('killed')).toBe('warning')
expect(jobDotState('failed')).toBe('error')
expect(jobStatusLabel('running', t)).toBe('jobStatusRunning')
expect(jobStatusLabel('stopping', t)).toBe('jobStatusStopping')
expect(jobStatusLabel('completed', t)).toBe('jobStatusCompleted')
expect(jobStatusLabel('killed', t)).toBe('jobStatusKilled')
expect(jobStatusLabel('failed', t)).toBe('jobStatusFailed')
})
it('formats durations in at most two adjacent units', () => {
expect(formatJobDuration(0, t)).toBe('0秒')
expect(formatJobDuration(45_000, t)).toBe('45秒')
expect(formatJobDuration(90_000, t)).toBe('1分30秒')
expect(formatJobDuration(3_661_000, t)).toBe('1小时1分')
// Negative or fractional input clamps to zero seconds.
expect(formatJobDuration(-5, t)).toBe('0秒')
})
})
describe('detectNewJob', () => {
const list = (jobsBySession: Record<string, SidebarJobView[]>): SidebarSessionList => ({
current: 'root',
byId: { root: { id: 'root', displayTitle: 'root' } },
subagentsByParent: {},
jobsBySession: jobsBySession,
})
it('fires on EVERY new job id for the session (not just the first)', () => {
expect(detectNewJob(list({}), list({ root: [job('bash-1')] }), 'root')).toBe(true)
expect(detectNewJob(
list({ root: [job('bash-1')] }),
list({ root: [job('bash-1'), job('bash-2')] }),
'root',
)).toBe(true)
})
it('stays quiet on settling, same ids, other sessions, or an absent mirror', () => {
// Settling only mutates status, never adds ids.
expect(detectNewJob(
list({ root: [job('bash-1')] }),
list({ root: [job('bash-1', { status: 'completed', finishedAt: 2_000 })] }),
'root',
)).toBe(false)
expect(detectNewJob(list({ root: [job('bash-1')] }), list({ root: [job('bash-1')] }), 'root')).toBe(false)
// Jobs owned by another session do not trigger the current one.
expect(detectNewJob(list({}), list({ child: [job('bash-1')] }), 'root')).toBe(false)
expect(detectNewJob(list({}), list({}), 'root')).toBe(false)
})
})
-165
View File
@@ -1,165 +0,0 @@
/**
* Pure-helper tests for the Subagent page's background-task section:
* tree-membership collection, ordering, and status presentation mapping.
*/
import { describe, expect, it } from 'vitest'
import {
collectTreeTasks,
detectNewTask,
formatTaskDuration,
isTaskLive,
orderTasks,
taskDotState,
taskStatusLabel,
treeSessionIds,
} from '../src/client/subagent-tasks.ts'
import type { SidebarSessionList, SidebarSessionSummary, SidebarTaskStatus, SidebarTaskView } from '../src/context-types.ts'
/** The translator stub: renders duration templates like the real locale copy. */
const templates: Record<string, string> = {
taskDurationSeconds: '{seconds}秒',
taskDurationMinutes: '{minutes}分{seconds}秒',
taskDurationHours: '{hours}小时{minutes}分',
}
const t = (key: string, params?: Record<string, string | number>): string => {
let text = templates[key] ?? key
if (params !== undefined) {
for (const [name, value] of Object.entries(params)) text = text.replaceAll(`{${name}}`, String(value))
}
return text
}
function summary(id: string, over: Partial<SidebarSessionSummary> = {}): SidebarSessionSummary {
return { id, displayTitle: `title-${id}`, ...over }
}
function task(id: string, over: Partial<SidebarTaskView> = {}): SidebarTaskView {
return { id, kind: 'bash', label: `cmd ${id}`, status: 'running', startedAt: 1_000, ...over }
}
describe('treeSessionIds', () => {
it('includes the root and every subagent-origin session whose chain reaches it', () => {
const byId = {
root: summary('root'),
child: summary('child', { origin: 'subagent', parentId: 'root' }),
grand: summary('grand', { origin: 'subagent', parentId: 'child' }),
orphan: summary('orphan', { origin: 'subagent', parentId: 'gone' }),
other: summary('other', { origin: 'subagent', parentId: 'other-root' }),
}
const ids = treeSessionIds(byId, 'root')
expect([...ids].sort()).toEqual(['child', 'grand', 'root'])
})
it('fails soft on parent cycles and yields nothing without a root', () => {
const byId = {
root: summary('root'),
a: summary('a', { origin: 'subagent', parentId: 'b' }),
b: summary('b', { origin: 'subagent', parentId: 'a' }),
}
expect(treeSessionIds(byId, 'root').size).toBe(1)
expect(treeSessionIds(byId, undefined).size).toBe(0)
})
})
describe('collectTreeTasks', () => {
it('collects tasks of the whole tree with owner titles, ignoring outside sessions', () => {
const byId = {
root: summary('root'),
child: summary('child', { origin: 'subagent', parentId: 'root' }),
}
const tasksBySession = {
root: [task('bash-1')],
child: [task('bash-2', { status: 'completed', finishedAt: 2_000 })],
stranger: [task('bash-9')],
}
const rows = collectTreeTasks(byId, tasksBySession, 'root')
expect(rows.map(row => [row.ownerSessionId, row.ownerTitle, row.task.id]))
.toEqual([['root', 'title-root', 'bash-1'], ['child', 'title-child', 'bash-2']])
})
it('returns an empty list for an absent mirror or empty sets', () => {
const byId = { root: summary('root') }
expect(collectTreeTasks(byId, undefined, 'root')).toEqual([])
expect(collectTreeTasks(byId, {}, 'root')).toEqual([])
})
})
describe('orderTasks', () => {
it('puts live rows first in start order, then settled rows newest-first', () => {
const row = (id: string, status: SidebarTaskStatus, startedAt: number, finishedAt?: number) => ({
ownerSessionId: 'root',
ownerTitle: 'root',
task: task(id, { status, startedAt, ...(finishedAt !== undefined ? { finishedAt } : {}) }),
})
const rows = [
row('old-settled', 'completed', 1_000, 2_000),
row('live-2', 'running', 4_000),
row('new-settled', 'killed', 1_500, 1_800),
row('live-1', 'stopping', 3_000),
]
expect(orderTasks(rows).map(r => r.task.id)).toEqual(['live-1', 'live-2', 'old-settled', 'new-settled'])
})
})
describe('status presentation helpers', () => {
it('treats running and stopping as live', () => {
expect(isTaskLive(task('a', { status: 'running' }))).toBe(true)
expect(isTaskLive(task('b', { status: 'stopping' }))).toBe(true)
expect(isTaskLive(task('c', { status: 'completed' }))).toBe(false)
expect(isTaskLive(task('d', { status: 'killed' }))).toBe(false)
expect(isTaskLive(task('e', { status: 'failed' }))).toBe(false)
})
it('maps the five wire statuses to dot states and localized labels', () => {
expect(taskDotState('running')).toBe('ongoing')
expect(taskDotState('stopping')).toBe('warning')
expect(taskDotState('completed')).toBe('done')
expect(taskDotState('killed')).toBe('warning')
expect(taskDotState('failed')).toBe('error')
expect(taskStatusLabel('running', t)).toBe('taskStatusRunning')
expect(taskStatusLabel('stopping', t)).toBe('taskStatusStopping')
expect(taskStatusLabel('completed', t)).toBe('taskStatusCompleted')
expect(taskStatusLabel('killed', t)).toBe('taskStatusKilled')
expect(taskStatusLabel('failed', t)).toBe('taskStatusFailed')
})
it('formats durations in at most two adjacent units', () => {
expect(formatTaskDuration(0, t)).toBe('0秒')
expect(formatTaskDuration(45_000, t)).toBe('45秒')
expect(formatTaskDuration(90_000, t)).toBe('1分30秒')
expect(formatTaskDuration(3_661_000, t)).toBe('1小时1分')
// Negative or fractional input clamps to zero seconds.
expect(formatTaskDuration(-5, t)).toBe('0秒')
})
})
describe('detectNewTask', () => {
const list = (tasksBySession: Record<string, SidebarTaskView[]>): SidebarSessionList => ({
current: 'root',
byId: { root: { id: 'root', displayTitle: 'root' } },
subagentsByParent: {},
tasksBySession: tasksBySession,
})
it('fires on EVERY new task id for the session (not just the first)', () => {
expect(detectNewTask(list({}), list({ root: [task('bash-1')] }), 'root')).toBe(true)
expect(detectNewTask(
list({ root: [task('bash-1')] }),
list({ root: [task('bash-1'), task('bash-2')] }),
'root',
)).toBe(true)
})
it('stays quiet on settling, same ids, other sessions, or an absent mirror', () => {
// Settling only mutates status, never adds ids.
expect(detectNewTask(
list({ root: [task('bash-1')] }),
list({ root: [task('bash-1', { status: 'completed', finishedAt: 2_000 })] }),
'root',
)).toBe(false)
expect(detectNewTask(list({ root: [task('bash-1')] }), list({ root: [task('bash-1')] }), 'root')).toBe(false)
// Tasks owned by another session do not trigger the current one.
expect(detectNewTask(list({}), list({ child: [task('bash-1')] }), 'root')).toBe(false)
expect(detectNewTask(list({}), list({}), 'root')).toBe(false)
})
})
+10 -10
View File
@@ -1150,7 +1150,7 @@ describe('side card preferences', () => {
openByDefault: false, openByDefault: false,
defaultWidthPercent: 60, defaultWidthPercent: 60,
autoOpenSubagent: false, autoOpenSubagent: false,
autoOpenTasks: true, autoOpenJobs: true,
agentTerminalTools: true, agentTerminalTools: true,
bottomPanelAutoTerminal: true, bottomPanelAutoTerminal: true,
interceptOpenPath: true, interceptOpenPath: true,
@@ -1169,7 +1169,7 @@ describe('side card preferences', () => {
openByDefault: true, openByDefault: true,
defaultWidthPercent: 33, defaultWidthPercent: 33,
autoOpenSubagent: true, autoOpenSubagent: true,
autoOpenTasks: true, autoOpenJobs: true,
agentTerminalTools: false, agentTerminalTools: false,
bottomPanelAutoTerminal: true, bottomPanelAutoTerminal: true,
interceptOpenPath: true, interceptOpenPath: true,
@@ -1188,7 +1188,7 @@ describe('side card preferences', () => {
openByDefault: false, openByDefault: false,
defaultWidthPercent: 40, defaultWidthPercent: 40,
autoOpenSubagent: true, autoOpenSubagent: true,
autoOpenTasks: true, autoOpenJobs: true,
agentTerminalTools: false, agentTerminalTools: false,
bottomPanelAutoTerminal: true, bottomPanelAutoTerminal: true,
interceptOpenPath: true, interceptOpenPath: true,
@@ -1208,10 +1208,10 @@ describe('side card preferences', () => {
.toBe(false) .toBe(false)
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools) expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools)
.toBe(true) .toBe(true)
// The task auto-open is ON by default; only an explicit false turns it off. // The job auto-open is ON by default; only an explicit false turns it off.
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenTasks: 1 }))).autoOpenTasks) expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenJobs: 1 }))).autoOpenJobs)
.toBe(true) .toBe(true)
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenTasks: false }))).autoOpenTasks) expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenJobs: false }))).autoOpenJobs)
.toBe(false) .toBe(false)
}) })
@@ -1242,9 +1242,9 @@ describe('side card preferences', () => {
const store = createSidebarStore() const store = createSidebarStore()
// Node environment: no window → the width falls back to PANEL_DEFAULT, // Node environment: no window → the width falls back to PANEL_DEFAULT,
// while the open flag still follows the preference. // while the open flag still follows the preference.
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenTasks: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
store.setSession('fresh-session') store.setSession('fresh-session')
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenTasks: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
const snapshot = store.getSnapshot() const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('fresh-session') expect(snapshot.sessionId).toBe('fresh-session')
expect(snapshot.state?.panelOpen).toBe(false) expect(snapshot.state?.panelOpen).toBe(false)
@@ -1280,7 +1280,7 @@ describe('side card preferences', () => {
it('skips the default explorer tab when the explorer type is disabled', () => { it('skips the default explorer tab when the explorer type is disabled', () => {
const store = createSidebarStore() const store = createSidebarStore()
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenTasks: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} }) store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} })
store.setSession('no-explorer') store.setSession('no-explorer')
const state = store.getSnapshot().state! const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs) const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
@@ -1288,7 +1288,7 @@ describe('side card preferences', () => {
expect(state.splits.kind).toBe('leaf') expect(state.splits.kind).toBe('leaf')
// Re-enabling seeds the explorer tab again. // Re-enabling seeds the explorer tab again.
const openStore = createSidebarStore() const openStore = createSidebarStore()
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenTasks: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
openStore.setSession('with-explorer') openStore.setSession('with-explorer')
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
expect(openTabs.map(tab => tab.type)).toEqual(['explorer']) expect(openTabs.map(tab => tab.type)).toEqual(['explorer'])