From 8c521be26eb797a1808f77b5216fe09a1a0859e2 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Wed, 12 Aug 2026 22:03:08 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20v0.8.0=20=E2=80=94=20=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E7=AE=A1=E7=90=86=E9=A1=B5=EF=BC=88=E5=AD=90=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E6=8B=93=E6=89=91=20+=20=E5=90=8E=E5=8F=B0=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E5=BA=95=E9=83=A8=E8=BE=93=E5=87=BA=20Dock=E3=80=81?= =?UTF-8?q?=E4=B8=A4=E5=87=BB=E7=A1=AE=E8=AE=A4=E7=BB=88=E6=AD=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tab 更名为任务管理:同页显示当前树全部后台命令(bash/pwsh 徽标、状态、时长、属主、退出码) - 点击任务行在侧边栏底部共享 Dock 显示输出(sticky 终端式、自动跟随、单一轮询实例) - 运行中任务两击确认强制终止;大量任务时列表保持紧凑、排序变动不跳动 - 新增 /sidebar/api tasks.output(非消费 peek,不偷模型 task_output 字节)与 tasks.kill 路由 - 修复任务镜像清空时的 React #300 hook 顺序崩溃(useCallback 移到提前 return 之前)+ 回归测试 --- README.md | 4 +- ...-08-12-subagent-background-tasks-design.md | 44 ++ dsh.plugin.json | 2 +- package.json | 2 +- src/client/SubagentView.module.css | 272 ++++++++++++ src/client/SubagentView.tsx | 399 +++++++++++++++--- src/client/api.ts | 24 ++ src/client/icons.tsx | 10 + src/client/locales.ts | 54 ++- src/client/subagent-tasks.ts | 144 +++++++ src/context-types.ts | 60 +++ src/index.ts | 24 ++ src/tasks-routes.ts | 83 ++++ src/wire.ts | 1 + tests/smoke.spec.ts | 10 + tests/subagent-tasks-view.spec.tsx | 267 ++++++++++++ tests/subagent-tasks.spec.ts | 133 ++++++ tests/tasks-routes.spec.ts | 107 +++++ 18 files changed, 1568 insertions(+), 72 deletions(-) create mode 100644 docs/plans/2026-08-12-subagent-background-tasks-design.md create mode 100644 src/client/subagent-tasks.ts create mode 100644 src/tasks-routes.ts create mode 100644 tests/subagent-tasks-view.spec.tsx create mode 100644 tests/subagent-tasks.spec.ts create mode 100644 tests/tasks-routes.spec.ts diff --git a/README.md b/README.md index 40707a0..e4d8515 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > 一个插件,一套完整工作台: > -> 文件管理、编辑预览、内嵌浏览器、真实终端、Git 面板、Subagent 页面——**右侧栏 + 底部面板双工作台**,一个插件全部搞定。 +> 文件管理、编辑预览、内嵌浏览器、真实终端、Git 面板、任务管理页——**右侧栏 + 底部面板双工作台**,一个插件全部搞定。 > > 支持Tab窗口随意拖拽,支持三方拓展注册新Tab页面和文件预览 @@ -18,7 +18,7 @@ https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86 - **🌐 浏览器**:内嵌网页浏览 tab(多开),后退/前进/刷新 +「在浏览器中打开」;页面在**沙箱 iframe** 中运行(不透明源:无法访问界面数据与本地文件,拒绝 localhost 等本机地址),界面实时显示沙箱状态、可临时解锁(关闭时红色警示);被站点拒绝嵌入(X-Frame-Options)时显示原因面板;聊天/界面里的 http(s) 外链默认在侧边栏打开(侧边栏折叠时自动展开面板) - **💻 终端**:xterm.js + node-pty 真实 shell(每会话 3 个 UI 上限)、Tab 保活重连回放;可选为模型注入 8 个 `terminal_*` 工具 - **🌿 Git 面板**:真 diff + VSCode 式 diff tab、懒加载历史、右键暂存/放弃/提交/还原/捡取 -- **🧩 Subagent 页面**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开 +- **🧩 任务管理页**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开;**同页显示后台命令**(当前树全部任务,bash/pwsh 类型徽标 + 退出码,点击查看实时输出——自动跟随底部、非消费 peek,不干扰模型的 `task_output`;两击确认可强制终止) - **🪟 底部面板**:独立的第二个工作台(与右侧栏同类的标签页),只挤占中间 Agent 输出区、不覆盖左右侧边栏;**首次展开自动开一个新终端**(终端卡片二级设置可关);右上角 x 一键折叠 - **📱 移动端**:视口 < 768px(真正的移动端宽度,不对齐宿主 1024 断点)时只显示右侧栏——进入窄屏时底部面板的标签页**直接并入右侧栏标签条**,右上角只剩一枚开关,面板为全宽抽屉;新会话默认收起,聊天里点文件/外链自动展开,不挤压对话区 - **🔧 分栏工作台**:拖 Tab 拆分/合并分栏(可**跨面板拖 Tab**)、分隔线调比例;右上角持久按钮簇(底栏 + 侧拉 glyph)折叠/展开两个面板;两面板共享拐角双向拖动调节尺寸,拖动 rAF 直写 DOM 保持流畅 diff --git a/docs/plans/2026-08-12-subagent-background-tasks-design.md b/docs/plans/2026-08-12-subagent-background-tasks-design.md new file mode 100644 index 0000000..e835485 --- /dev/null +++ b/docs/plans/2026-08-12-subagent-background-tasks-design.md @@ -0,0 +1,44 @@ +# 子代理页后台命令区块(查看输出 + 强制终止)设计 + +> 2026-08-12 · v0.8.0 + +## 目标 + +Subagent 页面(当前树的主代理拓扑)在拓扑树下方同页显示**当前树全部会话**(主代理 + 子代理)的后台任务: + +- 点击任务行展开输出面板,实时查看累积输出(运行中每 2s 轮询,页面不可见时暂停); +- 运行中的任务行带两击确认的终止按钮; +- 不破坏模型契约:查看输出走**非消费 peek**,绝不推进 `task_output` 的读取游标、不置 `reported` 位。 + +## 数据流 + +``` +bash/tool run_in_background → ctx.tasks.start(owner = 执行 agent) +api-proxy session/tasks 帧(TaskView[],无输出)→ 客户端 tasksBySession 镜像 +SubagentView 读镜像 → collectTreeTasks(树内会话) → 行渲染 / 展开面板 +展开面板 → POST /sidebar/api/tasks.output(非消费 peek)→ ctx.tasks.peek +终止 → POST /sidebar/api/tasks.kill → ctx.tasks.kill +``` + +## 关键决策 + +1. **非消费 peek 缝隙(harness 侧,跨仓库改动)**:现有 `TaskService.read()` 是单一消费游标——UI 读取会偷走模型 `task_output` 的字节(或读到空)。因此新增: + - `BashProcess.peekOutput()`:非消费读自启动以来的全部缓冲(`readFrom(0)`,不推进 offset); + - `TaskHooks.peekOutput?()` + `TaskService.peek(id, caller)`:返回全量累积文本,**不消费、不标记 reported**; + - tool-bash / tool-pwsh 在 background `TaskStart.run()` hooks 里接 `peekOutput`(复用 render 管线)。 + - 模型工具(tool-tasks 的 task_output/task_kill)零改动。 +2. **列表不新增路由**:任务清单已由 harness 的 `session/tasks` 推送镜像提供,只加 `tasks.output` / `tasks.kill` 两个路由(信任围栏与其余路由一致)。 +3. **访问围栏**:caller 由请求的 `sessionId` 现场解析 `ctx.agents.get(sessionId)`,注册表按 owner session id 拒绝 foreign 任务;未知/foreign 统一映射 404 `task-error`(不可区分存在性,与 api-proxy 的 view 保密一致)。 +4. **展示范围 = 整棵树**:与页面作用域一致(拓扑即当前树);树跨多会话时行尾显示属主标题。 +5. **两击确认终止**:首次点击进入「再次点击确认」态(3s 自动解除),防止误杀长任务。 +6. **输出上限**:路由按 `readLimit`(默认 512KB)截断并置 `truncated` 标记。 + +## 实施偏差 + +- 无。宿主不新增 `@deepseek-ai/*` 依赖(`ctx.tasks`/`ctx.agents` 走结构性镜像 + 运行时 `ctx.get`,可选服务降级 503)。 + +## 验证 + +- harness 单测:bash-local peek 不推进游标;tasks-local peek 幂等/不置 reported/围栏;tool-bash/pwsh 后台任务 peek 后 read 仍完整投递。 +- 插件单测:tasks-routes(caller 解析、截断、404/503 降级);subagent-tasks 纯函数;jsdom 视图(行渲染、展开取输出、两击终止、隐藏不轮询)。 +- 集成(真实 dsh web):启动 `run_in_background` bash → `/sidebar/api/tasks.output` 返回累积输出且模型 `task_output` 仍读到全部字节 → `/sidebar/api/tasks.kill` 后进程退出、任务结算 `killed (signal: SIGTERM)`、终态 peek 幂等。 diff --git a/dsh.plugin.json b/dsh.plugin.json index eefc5c5..88f99c1 100644 --- a/dsh.plugin.json +++ b/dsh.plugin.json @@ -1,6 +1,6 @@ { "id": "dsh-external/dsh-better-sidebar", - "version": "0.7.0", + "version": "0.8.0", "main": "./lib/index.js", "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器", "engines": { diff --git a/package.json b/package.json index 7c1a0c2..1231318 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-better-sidebar", - "version": "0.7.0", + "version": "0.8.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.", "private": true, "type": "module", diff --git a/src/client/SubagentView.module.css b/src/client/SubagentView.module.css index bd032e6..0354475 100644 --- a/src/client/SubagentView.module.css +++ b/src/client/SubagentView.module.css @@ -273,3 +273,275 @@ background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); } + +/* ── Background-task section (below the topology tree) ────────────────── */ + +.tasks { + margin-top: 10px; + border-top: 1px solid var(--dsw-alias-border-l2); + padding-top: 8px; +} + +.tasksHeader { + display: flex; + align-items: center; + gap: 8px; + height: 26px; + padding: 0 2px; +} + +.tasksTitle { + flex: 1; + min-width: 0; + font: var(--dsw-font-xxxs-strong-11); + color: var(--dsw-alias-label-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tasksCount { + flex: none; + font: var(--dsw-font-xxxs-11); + color: var(--dsw-alias-label-tertiary); +} + +.tasksList { + display: flex; + flex-direction: column; + gap: 2px; + margin: 0; + padding: 0; + list-style: none; +} + +/* One task row; the whole row lights up on hover, the expandable target + stays transparent (the kill button rides on the row's right edge). */ +.tasksRow { + display: flex; + align-items: center; + gap: 4px; + border-radius: 8px; +} + +.tasksRow:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.tasksRowSettled { + opacity: 0.8; +} + +/* The row whose output is shown in the bottom dock. */ +.tasksRowSelected, +.tasksRowSelected:hover { + background: var(--dsw-alias-interactive-bg-active); +} + +.tasksRowMain { + flex: 1; + display: flex; + align-items: flex-start; + gap: 8px; + min-width: 0; + padding: 6px 8px 6px 11px; + border: none; + border-radius: 8px; + background: transparent; + font: var(--dsw-font-s-14); + color: var(--dsw-alias-label-primary); + text-align: left; + cursor: pointer; + outline: none; +} + +.tasksRowMain:focus-visible { + background: var(--dsw-alias-interactive-bg-hover); +} + +.tasksDot { + margin-top: 5px; +} + +.tasksContent { + display: flex; + flex-direction: column; + min-width: 0; + gap: 1px; +} + +/* The command line: a kind badge followed by the command itself in the code + face, like the terminal's live lines. */ +.tasksLabelLine { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.tasksKind { + flex: none; + max-width: 90px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0 5px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 4px; + font: var(--dsw-font-xxxs-strong-11); + line-height: 14px; + color: var(--dsw-alias-label-tertiary); +} + +.tasksLabel { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--ds-font-family-code); + font-size: var(--dsw-font-xxxs-11-font-size); + line-height: var(--dsw-font-xxxs-11-line-height); + color: var(--dsw-alias-label-primary); +} + +.tasksSecondary { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: var(--dsw-font-xxxs-11); + color: var(--dsw-alias-label-tertiary); +} + +.tasksKill { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + margin-right: 4px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.tasksKill:hover { + background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); + color: var(--dsw-alias-state-error-primary); +} + +/* The armed (first-click) state: a filled confirm chip instead of the icon. */ +.tasksKillArmed, +.tasksKillArmed:hover { + width: auto; + height: 20px; + padding: 0 8px; + background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); + color: var(--dsw-alias-state-error-primary); + font: var(--dsw-font-xxxs-strong-11); + white-space: nowrap; +} + +.tasksKill:disabled { + opacity: 0.5; + cursor: default; +} + +.tasksKillError { + flex: none; + margin-right: 4px; + font: var(--dsw-font-xxxs-11); + color: var(--dsw-alias-state-error-primary); +} + +/* ── 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 + edge while the task list scrolls above it; a solid background keeps the + tree from showing through. */ +.tasksPane { + position: sticky; + bottom: 0; + z-index: 1; + margin-top: 4px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + background: var(--dsw-alias-bg-base); + box-shadow: 0 -6px 12px -8px rgba(0, 0, 0, 0.35); + overflow: hidden; +} + +.tasksPaneHeader { + display: flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 4px 0 10px; + border-bottom: 1px solid var(--dsw-alias-border-l1); +} + +.tasksPaneDot { + flex: none; +} + +.tasksPaneLabel { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--ds-font-family-code); + font-size: var(--dsw-font-xxxs-11-font-size); + line-height: var(--dsw-font-xxxs-11-line-height); + color: var(--dsw-alias-label-primary); +} + +.tasksPaneStatus { + flex: none; + font: var(--dsw-font-xxxs-11); + color: var(--dsw-alias-label-tertiary); +} + +.tasksPaneClose { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border: none; + border-radius: 5px; + background: transparent; + color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.tasksPaneClose:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.tasksPanePre { + margin: 0; + max-height: 200px; + overflow: auto; + padding: 6px 10px; + font-family: var(--ds-font-family-code); + font-size: var(--dsw-font-xxxs-11-font-size); + line-height: 1.5; + color: var(--dsw-alias-label-primary); + white-space: pre-wrap; + word-break: break-word; +} + +.tasksPaneHint { + padding: 8px 10px; + font: var(--dsw-font-xxxs-11); + color: var(--dsw-alias-label-tertiary); +} + +.tasksPaneError { + color: var(--dsw-alias-state-error-primary); +} diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx index 414b570..4f2ab7d 100644 --- a/src/client/SubagentView.tsx +++ b/src/client/SubagentView.tsx @@ -27,11 +27,13 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context, + SidebarSessionList, SidebarSessionSummary, SidebarSubagentAddress, SidebarSubagentCatalog, SidebarSubagentChildEntry, SidebarSubagentDiagnosticEntry, + SidebarTaskView, } from '../context-types.ts' import { collectBranchIds, @@ -39,6 +41,17 @@ import { rootAncestor, } from './subagent-detect.ts' import { lastActivity } from './subagent-activity.ts' +import { + collectTreeTasks, + formatTaskDuration, + isTaskLive, + orderTasks, + taskDotState, + taskStatusLabel, + type TreeTask, +} from './subagent-tasks.ts' +import { api, type TaskOutputResult } from './api.ts' +import { IconStopOutline16 } from './icons.tsx' import { t } from './locales.ts' import css from './SubagentView.module.css' @@ -46,6 +59,10 @@ import css from './SubagentView.module.css' const POLL_MS = 3000 /** Preview cap of one tool-call argument line. */ const ARGS_PREVIEW = 60 +/** Refresh cadence of an expanded task-output panel while its task runs. */ +const TASK_POLL_MS = 2000 +/** How long the kill button stays armed before it needs re-confirming. */ +const TASK_KILL_ARM_MS = 3000 /** The direct subagent children of one parent (durable `origin` rows). */ function directChildren( @@ -340,6 +357,259 @@ function CatalogRows({ ) } +/** + * The shared output dock of the tasks section: ONE pane at the bottom of the + * sidebar body (sticky, terminal-like) shows the SELECTED task's FULL + * accumulated output via the host's NON-CONSUMING peek (`tasks.output`), + * refreshed every {@link TASK_POLL_MS} while the task runs and the page is + * visible. The model's own `task_output` cursor is never touched, so + * watching a stream cannot steal the agent's bytes. A single dock — not a + * panel per row — keeps the task list compact and stable when many tasks + * are running. + */ +function TaskOutputPane(props: { + ownerSessionId: string + task: SidebarTaskView + /** The page is visible (active tab + open panel): skip polling otherwise. */ + active: boolean + onClose: () => void +}) { + const { ownerSessionId, task, active, onClose } = props + const [state, setState] = useState<'loading' | TaskOutputResult | 'error'>('loading') + const controllerRef = useRef(undefined) + const preRef = useRef(null) + + const load = useCallback(async (): Promise => { + controllerRef.current?.abort() + const controller = new AbortController() + controllerRef.current = controller + try { + const result = await api.taskOutput({ sessionId: ownerSessionId }, task.id, controller.signal) + setState(result) + } catch { + // 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. + setState(current => (current === 'loading' ? 'error' : current)) + } + }, [ownerSessionId, task.id]) + + useEffect(() => { + void load() + if (!active || !isTaskLive(task)) return + const timer = window.setInterval(() => { void load() }, TASK_POLL_MS) + return () => { window.clearInterval(timer) } + }, [load, active, task.status]) + + useEffect(() => () => { controllerRef.current?.abort() }, []) + + // Terminal-tail behavior: while the task runs, each refresh pins the view + // to the newest output; a settled dock leaves scrolling to the reader. + useEffect(() => { + if (!isTaskLive(task) || typeof state !== 'object' || state.text.length === 0) return + const pre = preRef.current + if (pre !== null) pre.scrollTop = pre.scrollHeight + }, [state, task.status]) + + return ( +
+
+ + {task.label} + + {taskStatusLabel(task.status, t)} + {task.detail !== undefined && task.detail !== '' ? ` · ${task.detail}` : ''} + + +
+ {state === 'loading' &&
{t('loading')}
} + {state === 'error' && ( +
{t('taskOutputError')}
+ )} + {typeof state === 'object' && ( + <> + {state.text.length > 0 + ?
{state.text}
+ :
{t('taskNoOutput')}
} + {state.truncated &&
{t('taskOutputTruncated')}
} + + )} +
+ ) +} + +/** + * The background-task section of the Subagent page: every task of the whole + * current tree (main agent + subagents, owner-labeled), fed by the harness + * `session/tasks` push mirror. Clicking a row feeds its output to the shared + * bottom dock (non-consuming peek); live rows carry a two-click-confirm + * kill button. Renders nothing while the tree has no tasks. + */ +function TasksSection(props: { + byId: SidebarSessionList['byId'] + tasksBySession: SidebarSessionList['tasksBySession'] + rootId: string | undefined + /** The page is visible (active tab + open panel): skip polling otherwise. */ + active: boolean +}) { + const { byId, tasksBySession, rootId, active } = props + const rows = useMemo( + () => orderTasks(collectTreeTasks(byId, tasksBySession, rootId)), + [byId, tasksBySession, rootId], + ) + const [selectedId, setSelectedId] = useState(undefined) + const [armedId, setArmedId] = useState(undefined) + const [killingId, setKillingId] = useState(undefined) + const [killErrorId, setKillErrorId] = useState(undefined) + // The duration clock only runs while a live row is on screen. + const [now, setNow] = useState(() => Date.now()) + + const selectedRow = useMemo( + () => (selectedId === undefined ? undefined : rows.find(row => row.task.id === selectedId)), + [rows, selectedId], + ) + + const liveCount = useMemo( + () => rows.reduce((count, row) => count + (isTaskLive(row.task) ? 1 : 0), 0), + [rows], + ) + const multiOwner = useMemo( + () => new Set(rows.map(row => row.ownerSessionId)).size > 1, + [rows], + ) + + // The kill button stays armed only briefly; a stray click must never kill. + useEffect(() => { + if (armedId === undefined) return + const timer = window.setTimeout(() => { setArmedId(undefined) }, TASK_KILL_ARM_MS) + return () => { window.clearTimeout(timer) } + }, [armedId]) + + useEffect(() => { + if (liveCount === 0) return + setNow(Date.now()) + const timer = window.setInterval(() => { setNow(Date.now()) }, 1_000) + return () => { window.clearInterval(timer) } + }, [liveCount]) + + // The docked output pane follows its task: when the selected task leaves + // the mirror (settled and dropped, or the tree switched), close the dock. + useEffect(() => { + if (selectedId !== undefined && selectedRow === undefined) setSelectedId(undefined) + }, [selectedId, selectedRow]) + + // 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 + // React with "Rendered fewer hooks than expected" (the #300 regression). + const kill = useCallback(async (row: TreeTask): Promise => { + setKillingId(row.task.id) + setKillErrorId(undefined) + try { + await api.taskKill({ sessionId: row.ownerSessionId }, row.task.id) + } catch { + setKillErrorId(row.task.id) + } finally { + setKillingId(undefined) + setArmedId(undefined) + } + }, []) + + if (rows.length === 0) return null + + const countLabel = liveCount > 0 + ? t('tasksCountRunning', { count: rows.length, running: liveCount }) + : t('tasksCount', { count: rows.length }) + + return ( + <> +
+
+ {t('tasks')} + {countLabel} +
+
    + {rows.map((row) => { + const { task } = row + const live = isTaskLive(task) + const selected = selectedId === task.id + const armed = armedId === task.id + const killing = killingId === task.id + const killFailed = killErrorId === task.id + const elapsed = live + ? now - task.startedAt + : (task.finishedAt ?? task.startedAt) - task.startedAt + const secondary = [ + ...(multiOwner ? [row.ownerTitle] : []), + taskStatusLabel(task.status, t), + ...(task.detail !== undefined && task.detail !== '' ? [task.detail] : []), + formatTaskDuration(elapsed, t), + ].filter(Boolean).join(' · ') + return ( +
  • + + {task.status === 'running' && ( + + )} + {killFailed && {t('taskKillError')}} +
  • + ) + })} +
+
+ {selectedRow !== undefined && ( + { setSelectedId(undefined) }} + /> + )} + + ) +} + /** * The sidebar's Subagent topology page. * @param props - current session id, whether the page is actually visible @@ -515,69 +785,78 @@ export function SubagentView(props: {
- {rootId !== undefined && rootSummary !== undefined && ( -
{ - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - event.stopPropagation() - openMain() - } - }} - > - - - - {rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} - - - {`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`} - - -
- )} - {rootId !== undefined && ( -
- {summaryBackedLoading && ( - - )} - {!summaryBackedLoading && ( - + {rootId !== undefined && rootSummary !== undefined && ( +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + event.stopPropagation() + openMain() + } + }} + > + - )} -
- )} - {readyEmpty && ( -
-
{t('subagentEmpty')}
-
{t('subagentEmptyDesc')}
-
- )} + + + {rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} + + + {`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`} + + +
+ )} + {rootId !== undefined && ( +
+ {summaryBackedLoading && ( + + )} + {!summaryBackedLoading && ( + + )} +
+ )} + {readyEmpty && ( +
+
{t('subagentEmpty')}
+
{t('subagentEmptyDesc')}
+
+ )} +
+ ) diff --git a/src/client/api.ts b/src/client/api.ts index c9865a2..20687bf 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -8,6 +8,7 @@ */ import { encodeHtmlUrl } from '../html-route.ts' import type { BrowserProbeResult } from './browser.ts' +import type { SidebarTaskStatus } from '../context-types.ts' /** One wire failure. */ export class SidebarApiError extends Error { @@ -60,6 +61,15 @@ export interface FsTextResult { kind: 'text'; content: string; truncated: boolea * `head` carries the first bytes (base64) for viewer detect sniffing. */ export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string } +/** One tasks.output response: the peeked full text (capped) plus the wire status. */ +export interface TaskOutputResult { + text: string + /** True when the host capped the text at its output limit. */ + truncated: boolean + status: SidebarTaskStatus + detail?: string +} + async function call(method: string, payload: Record, signal?: AbortSignal): Promise { let response: Response try { @@ -145,6 +155,20 @@ export const api = { /** Release an agent terminal by uuid (tab closed while WS was down). */ agentPtyClose: (uuid: string) => call<{ ok: true }>('agent-pty.close', { uuid }), + /** + * Full accumulated output of one background task (the host's NON-CONSUMING + * peek — the model's task_output cursor and report bit are untouched, so a + * human watching a stream never steals the agent's bytes). The scope MUST + * be the task's OWNER session (the tasks registry fences by session id). + */ + taskOutput: (scope: SessionScope, id: string, signal?: AbortSignal) => + call('tasks.output', scopePayload(scope, { id }), signal), + /** Request cancellation of one background task (live tasks flip to stopping). */ + taskKill: (scope: SessionScope, id: string, reason?: string) => + call<{ ok: true; outcome: 'requested' | 'already-finished' }>('tasks.kill', scopePayload(scope, { + id, + ...(reason !== undefined ? { reason } : {}), + })), /** Read the side card preferences (plugin-global, no session scope). */ settingsGet: () => call<{ value?: unknown; revision?: number }>('settings.get', {}), diff --git a/src/client/icons.tsx b/src/client/icons.tsx index 8e61af1..ae16e12 100644 --- a/src/client/icons.tsx +++ b/src/client/icons.tsx @@ -51,6 +51,16 @@ export const IconDiffOutline16 = ({ size = 16, className }: IconProps) => ( ) +/** + * Stop glyph for the background-task kill button: a filled square in the + * app's outline scale (16), the universal "halt this work" mark. + */ +export const IconStopOutline16 = ({ size = 16, className }: IconProps) => ( + + + +) + // ── File-viewer inventory glyphs (Side card settings page) ──────────────── /** Image viewer glyph: a picture frame with a sun and a mountain. */ diff --git a/src/client/locales.ts b/src/client/locales.ts index b995f55..4a37958 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -119,8 +119,8 @@ const zh = { nextSlide: '下一页', zoom: '缩放', zoomHint: 'Alt + 滚轮', - settingsSubagentTitle: '检测到子代理时自动展开子代理页面', - settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开子代理页面;关闭后需手动打开', + settingsSubagentTitle: '检测到子代理时自动展开任务管理页', + settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开任务管理页;关闭后需手动打开', settingsToolsTitle: '为模型注入终端工具', settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)', settingsBottomTerminalTitle: '底部面板首次展开自动开终端', @@ -165,8 +165,8 @@ const zh = { browserEmbedBlocked: '{host} 拒绝了嵌入请求', browserEmbedBlockedDesc: '该站点通过 X-Frame-Options / frame-ancestors 禁止在其它页面中显示,无法在侧边栏内加载。可在浏览器中直接打开', browserEmbedAnyway: '仍然加载', - subagent: '子代理', - openSubagent: '子代理', + subagent: '任务管理', + openSubagent: '任务管理', subagentMainAgent: '主代理', subagentEmpty: '暂无子代理', subagentEmptyDesc: '当前主代理派生的子代理将显示在这里', @@ -180,6 +180,25 @@ const zh = { subagentDiagUnsupported: '不支持的条目', subagentDiagUnavailable: '不可用', subagentThinking: '思考中…', + tasks: '后台命令', + tasksCount: '{count} 个后台命令', + tasksCountRunning: '{count} 个后台命令 · {running} 运行中', + taskStatusRunning: '运行中', + taskStatusStopping: '终止中', + taskStatusCompleted: '已完成', + taskStatusKilled: '已终止', + taskStatusFailed: '失败', + taskDurationSeconds: '{seconds} 秒', + taskDurationMinutes: '{minutes} 分 {seconds} 秒', + taskDurationHours: '{hours} 小时 {minutes} 分', + taskViewOutput: '查看输出', + taskHideOutput: '收起输出', + taskNoOutput: '暂无输出', + taskOutputTruncated: '输出过长,已截断显示', + taskOutputError: '输出读取失败', + taskKill: '终止', + taskKillConfirm: '再次点击确认终止', + taskKillError: '终止失败', } const en: Record = { @@ -296,8 +315,8 @@ const en: Record = { nextSlide: 'Next', zoom: 'Zoom', zoomHint: 'Alt + wheel', - settingsSubagentTitle: 'Auto-open the Subagent page when a subagent appears', - settingsSubagentDesc: 'Expand the side card and open the Subagent page when the current conversation spawns a new subagent; turn off to open it manually', + 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', 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)', settingsBottomTerminalTitle: 'Auto-open a terminal on the bottom panel\'s first expansion', @@ -342,8 +361,8 @@ const en: Record = { browserEmbedBlocked: '{host} refused to be embedded', browserEmbedBlockedDesc: 'The site forbids being displayed inside other pages (X-Frame-Options / frame-ancestors), so it cannot load in the sidebar. Open it directly in your browser instead.', browserEmbedAnyway: 'Load anyway', - subagent: 'Subagents', - openSubagent: 'Subagents', + subagent: 'Tasks', + openSubagent: 'Tasks', subagentMainAgent: 'Main agent', subagentEmpty: 'No subagents', subagentEmptyDesc: 'Subagents spawned under the main agent will appear here', @@ -357,6 +376,25 @@ const en: Record = { subagentDiagUnsupported: 'Unsupported', subagentDiagUnavailable: 'Unavailable', subagentThinking: 'Thinking…', + tasks: 'Background tasks', + tasksCount: '{count} background tasks', + tasksCountRunning: '{count} background tasks · {running} running', + taskStatusRunning: 'Running', + taskStatusStopping: 'Stopping', + taskStatusCompleted: 'Completed', + taskStatusKilled: 'Killed', + taskStatusFailed: 'Failed', + taskDurationSeconds: '{seconds}s', + taskDurationMinutes: '{minutes}m {seconds}s', + taskDurationHours: '{hours}h {minutes}m', + taskViewOutput: 'View output', + taskHideOutput: 'Hide output', + taskNoOutput: 'No output yet', + taskOutputTruncated: 'Output truncated', + taskOutputError: 'Failed to read output', + taskKill: 'Kill', + taskKillConfirm: 'Click again to confirm kill', + taskKillError: 'Kill failed', } /** Translate a copy key in the browser's language (zh → zh, else en). */ diff --git a/src/client/subagent-tasks.ts b/src/client/subagent-tasks.ts new file mode 100644 index 0000000..38bccd6 --- /dev/null +++ b/src/client/subagent-tasks.ts @@ -0,0 +1,144 @@ +/** + * Pure derivations for the Subagent page's background-task section. Kept + * framework-free so the node test environment can unit-test them: the task + * rows arrive through the harness `session/tasks` push mirror + * (`tasksBySession` in the sessions list feed) — nothing here issues + * requests, and the row ordering / status mapping mirror the official + * ui-task header list. + */ +import type { + SidebarSessionList, + SidebarSessionSummary, + SidebarTaskStatus, + SidebarTaskView, +} from '../context-types.ts' +import type { CopyKey } from './locales.ts' + +/** One row of the tasks section: the task plus its owning session's title. */ +export interface TreeTask { + ownerSessionId: string + ownerTitle: string + task: SidebarTaskView +} + +/** Whether the registry still holds the task open (its duration ticks). */ +export function isTaskLive(task: SidebarTaskView): boolean { + return task.status === 'running' || task.status === 'stopping' +} + +/** + * Every session id of the topology tree rooted at `rootId` (the root plus + * each session whose uninterrupted subagent-origin chain reaches it — same + * lineage semantics as {@link countSubagentDescendants}; cycles fail soft). + * Sessions outside the tree (orphans, other trees) are excluded, so the + * tasks section never shows foreign work. + */ +export function treeSessionIds( + byId: SidebarSessionList['byId'], + rootId: string | undefined, +): Set { + const ids = new Set() + if (rootId === undefined) return ids + for (const summary of Object.values(byId)) { + const seen = new Set() + let current: SidebarSessionSummary | undefined = summary + let reachesRoot = false + while (current !== undefined && !seen.has(current.id)) { + seen.add(current.id) + if (current.id === rootId) { + reachesRoot = true + break + } + if (current.origin !== 'subagent' || current.parentId === undefined) break + current = byId[current.parentId] + } + if (reachesRoot) ids.add(summary.id) + } + return ids +} + +/** + * Collect the background tasks of the whole current tree, owner-labeled. + * Sessions without a mirror entry contribute nothing; an absent mirror + * (runtime older than the tasks feed) yields an empty list. + */ +export function collectTreeTasks( + byId: SidebarSessionList['byId'], + tasksBySession: Readonly> | undefined, + rootId: string | undefined, +): TreeTask[] { + const rows: TreeTask[] = [] + if (tasksBySession === undefined) return rows + for (const sessionId of treeSessionIds(byId, rootId)) { + const tasks = tasksBySession[sessionId] + if (tasks === undefined || tasks.length === 0) continue + const ownerTitle = byId[sessionId]?.displayTitle ?? sessionId + for (const task of tasks) rows.push({ ownerSessionId: sessionId, ownerTitle, task }) + } + return rows +} + +/** + * 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 + * sort never depends on the host's map iteration. + */ +export function orderTasks(rows: readonly TreeTask[]): TreeTask[] { + return [...rows].sort((left, right) => { + const liveLeft = isTaskLive(left.task) + if (liveLeft !== isTaskLive(right.task)) return liveLeft ? -1 : 1 + if (liveLeft) return left.task.startedAt - right.task.startedAt + const finished = (right.task.finishedAt ?? right.task.startedAt) - (left.task.finishedAt ?? left.task.startedAt) + return finished !== 0 ? finished : left.task.startedAt - right.task.startedAt + }) +} + +/** The sidebar's StateDot states for the five wire statuses. */ +export type TaskDotState = 'ongoing' | 'warning' | 'done' | 'error' + +/** + * Status marker semantics. `stopping` and `killed` share the attention + * color: both mean the work ended (or is ending) on request rather than on + * its own. + */ +export function taskDotState(status: SidebarTaskStatus): TaskDotState { + switch (status) { + case 'running': return 'ongoing' + case 'stopping': return 'warning' + case 'completed': return 'done' + case 'killed': return 'warning' + case 'failed': return 'error' + } +} + +/** Human status word of one wire status (localized through the passed translator). */ +export function taskStatusLabel( + status: SidebarTaskStatus, + t: (key: CopyKey, params?: Record) => string, +): string { + switch (status) { + case 'running': return t('taskStatusRunning') + case 'stopping': return t('taskStatusStopping') + case 'completed': return t('taskStatusCompleted') + case 'killed': return t('taskStatusKilled') + case 'failed': return t('taskStatusFailed') + } +} + +/** + * Elapsed time in at most two adjacent units (mirror of the official + * ui-task duration wording). A background task that outlives an hour is + * already exceptional, so hours is the widest unit. + */ +export function formatTaskDuration( + elapsedMs: number, + t: (key: CopyKey, params?: Record) => string, +): string { + const total = Math.max(0, Math.floor(elapsedMs / 1_000)) + const seconds = total % 60 + const minutes = Math.floor(total / 60) % 60 + const hours = Math.floor(total / 3_600) + if (hours > 0) return t('taskDurationHours', { hours, minutes }) + if (minutes > 0) return t('taskDurationMinutes', { minutes, seconds }) + return t('taskDurationSeconds', { seconds }) +} diff --git a/src/context-types.ts b/src/context-types.ts index ba7716b..aab08ea 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -150,6 +150,50 @@ export interface SidebarHistoryEntry { view?: unknown } +/** Lifecycle status set of one background task (closed wire union). */ +export type SidebarTaskStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed' + +/** + * One background task as the client mirror sees it (wire `TaskView` shape: + * id/kind/label/status/detail?/startedAt/finishedAt?). + */ +export interface SidebarTaskView { + /** Registry-issued `-N` identity, stable for the task's whole life. */ + id: string + /** Producer kind (`bash`, `pwsh`, `subagent`, …; open string by design). */ + kind: string + /** Producer-supplied one-line label: the command, or the delegation description. */ + label: string + /** Current lifecycle state. */ + status: SidebarTaskStatus + /** Kind-specific status detail ('exit code: 3'), present once supplied. */ + detail?: string + /** Epoch ms when the task was registered. */ + startedAt: number + /** Epoch ms when the task settled; absent while live. */ + finishedAt?: number +} + +/** The host tasks registry face the sidebar routes touch (structural mirror of `TaskService`). */ +export interface SidebarTasksService { + /** + * Non-consuming full-output read (never advances the model's read cursor, + * never marks the task reported). Throws for an unknown or foreign task. + */ + peek(id: string, caller?: SidebarAgent): { + text: string + snapshot: { status: SidebarTaskStatus; detail?: string } + } + /** Request cancellation; throws for an unknown or foreign task. */ + kill(id: string, caller?: SidebarAgent, reason?: string): 'requested' | 'already-finished' +} + +/** The host agent registry face (structural mirror of the runtime `ctx.agents`). */ +export interface SidebarAgentsService { + /** The live agent registered under a session id, or undefined when not live. */ + get(id: string): SidebarAgent | undefined +} + /** RPC result slot mirror (`RpcResult` on the wire). */ export type SidebarRpcResult = { ok: true; value: T } | { ok: false; error: { code: string; message: string } } @@ -177,6 +221,12 @@ export interface SidebarSessionList { byId: Record /** Direct durable catalogs keyed by their selected parent address. */ subagentsByParent?: Readonly> + /** + * Background tasks per session, last-wins from the harness's `session/tasks` + * push (a missing key is an empty set). Absent on runtime snapshots older + * than the tasks mirror — the sidebar simply shows no task rows. + */ + tasksBySession?: Readonly> } /** The client sessions service face (only the list feed is needed). */ @@ -322,6 +372,16 @@ declare module 'cordis' { settings: SidebarSettingsService invariants: SidebarInvariantsService tools: SidebarToolsService + /** + * The host background-task registry (`ctx.get('tasks')`; optional — the + * sidebar routes degrade to a 503 when the deployment lacks it). + */ + tasks: SidebarTasksService + /** + * The host live-agent registry (`ctx.get('agents')`; optional — used to + * resolve the caller the tasks fence compares against). + */ + agents: SidebarAgentsService /** * The client-side sidebar registry: external plugins register tab types * and file previewers here. Provided by the client half (see diff --git a/src/index.ts b/src/index.ts index 4b919dd..b3d67b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,7 @@ import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts' import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts' import { registerTools } from './tools.ts' +import { buildTasksApi, type SidebarTasksRoutes } from './tasks-routes.ts' import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts' export { Config } @@ -198,6 +199,11 @@ function buildApi( const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined return { sessionId, cwd: sessionCwdOf(ctx, sessionId, clientCwd) } } + // Background tasks: the LIST rides the harness's `session/tasks` push + // mirror, so these routes only read output (a non-consuming peek — the + // model's task_output cursor is never touched) and kill. A deployment + // without the tasks registry downgrades to a 503, like the settings routes. + const tasksApi: SidebarTasksRoutes | undefined = buildTasksApi(ctx, resolved.readLimit) return { 'session.cwd': (payload) => { const { sessionId, cwd } = cwdOf(payload) @@ -327,6 +333,24 @@ function buildApi( agentPtyRegistry.close(uuid) return { ok: true } }, + // Background tasks: read one task's FULL output (a non-consuming peek — + // the model's task_output cursor and report bit are untouched, so a + // human watching a stream never steals the agent's bytes), and kill one + // task. The task LIST itself arrives through the harness's session/tasks + // push mirror, so no list route exists. Access is fenced to the owning + // session by the tasks registry (the caller resolves from sessionId). + 'tasks.output': (payload) => { + if (tasksApi === undefined) { + throw new SidebarError('task-error', 'the background-task registry is not mounted in this deployment', 503) + } + return tasksApi.output(payload) + }, + 'tasks.kill': (payload) => { + if (tasksApi === undefined) { + throw new SidebarError('task-error', 'the background-task registry is not mounted in this deployment', 503) + } + return tasksApi.kill(payload) + }, // The side card preferences. The settings service is optional in the // composition; while absent the routes report undefined and the client // keeps the schema defaults. Writes are revision-guarded: a stale editor diff --git a/src/tasks-routes.ts b/src/tasks-routes.ts new file mode 100644 index 0000000..eb06419 --- /dev/null +++ b/src/tasks-routes.ts @@ -0,0 +1,83 @@ +/** + * Background-task routes of the /sidebar JSON API ('tasks.output' / + * 'tasks.kill'). The task LIST itself needs no route: it arrives through the + * harness's `session/tasks` push mirror (`tasksBySession` in the sessions + * list feed). These routes only: + * + * - READ OUTPUT — a NON-CONSUMING peek (`ctx.tasks.peek`) that never advances + * the model's `task_output` cursor nor claims the completion report, so a + * human watching a stream cannot steal the agent's bytes; + * - KILL tasks (`ctx.tasks.kill`) with a forwarded reason. + * + * Both are fenced by the owning session: the caller Agent is resolved live + * from the requested sessionId (`ctx.agents.get`) and the tasks registry + * refuses anything that session does not own. Registry errors map to a 404 + * `task-error` (unknown/foreign ids), so the client cannot distinguish + * existence — matching the api-proxy's task-view secrecy. + */ +import type { Context, SidebarTaskStatus } from './context-types.ts' +import { requireString, SidebarError } from './wire.ts' + +/** The two background-task routes of the sidebar API. */ +export interface SidebarTasksRoutes { + /** Full accumulated output of one task (non-consuming peek, capped). */ + output(payload: unknown): { + text: string + truncated: boolean + status: SidebarTaskStatus + detail?: string + } + /** Request cancellation of one task (live tasks flip to stopping). */ + kill(payload: unknown): { ok: true; outcome: 'requested' | 'already-finished' } +} + +/** + * Build the tasks routes bound to the plugin context. Returns undefined when + * the deployment lacks the background-task registry (`ctx.tasks`) — the + * calling route then reports a 503, mirroring the settings routes' optional + * service downgrade. + * @param ctx - host plugin context (tasks/agents read lazily at call time). + * @param outputLimit - response cap for one output read in bytes; longer + * texts are sliced and flagged `truncated` (mirrors the fs.read cap). + */ +export function buildTasksApi(ctx: Context, outputLimit: number): SidebarTasksRoutes | undefined { + const tasks = ctx.get('tasks') + if (tasks === undefined) return undefined + const agents = ctx.get('agents') + /** The live caller whose session id the fence compares against (absent → unowned only). */ + const callerOf = (sessionId: string) => agents?.get(sessionId) + /** Registry refusals become a 404 task-error; unknown and foreign ids are indistinguishable. */ + const registryError = (error: unknown): SidebarError => + new SidebarError('task-error', error instanceof Error ? error.message : String(error), 404) + return { + output(payload) { + const sessionId = requireString(payload, 'sessionId') + const id = requireString(payload, 'id') + try { + const read = tasks.peek(id, callerOf(sessionId)) + const text = read.text + return { + text: text.length > outputLimit ? text.slice(0, outputLimit) : text, + truncated: text.length > outputLimit, + status: read.snapshot.status, + ...read.snapshot.detail !== undefined ? { detail: read.snapshot.detail } : {}, + } + } catch (error) { + throw registryError(error) + } + }, + kill(payload) { + const sessionId = requireString(payload, 'sessionId') + const id = requireString(payload, 'id') + const record = payload as { reason?: unknown } | null + const reason = typeof record?.reason === 'string' && record.reason !== '' + ? record.reason + : 'user requested via sidebar' + try { + return { ok: true, outcome: tasks.kill(id, callerOf(sessionId), reason) } + } catch (error) { + throw registryError(error) + } + }, + } +} diff --git a/src/wire.ts b/src/wire.ts index 403277f..53ce473 100644 --- a/src/wire.ts +++ b/src/wire.ts @@ -15,6 +15,7 @@ export type SidebarErrorCode = | 'fs-error' | 'git-error' | 'pty-error' + | 'task-error' | 'settings-rejected' | 'settings-conflict' | 'internal' diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 2b8ed93..5c6d5c2 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -27,6 +27,8 @@ interface FakeContext { /** The settings service never appears in the smoke context: the inject * callback must never run (mirror of cordis' service-less inject). */ inject: (deps: readonly string[], callback: (sctx: never) => void) => () => void + /** Optional services (tasks/agents) are read lazily; absent → undefined. */ + get: (key: string) => undefined } describe('host plugin smoke', () => { @@ -56,6 +58,8 @@ describe('host plugin smoke', () => { // No settings service in the smoke context: the registration callback // never runs (cordis' service-less inject behaves the same). inject: () => () => {}, + // No tasks/agents services: the tasks routes degrade to a 503. + get: () => undefined, } apply(ctx as never) expect(routes.map(route => route.path)).toEqual(['/sidebar/api', '/sidebar/bundle', '/sidebar/file', '/sidebar/html']) @@ -315,6 +319,8 @@ describe('session cwd resolution over the API route', () => { effect: (fn: () => void | (() => void)) => { fn() }, // No settings service: the namespace registration never runs. inject: () => () => {}, + // No tasks/agents services in the smoke context: the routes degrade. + get: () => undefined, } apply(ctx as never) return routes.find(route => route.path === '/sidebar/api')! @@ -464,6 +470,8 @@ describe('side card settings routes', () => { if (deps.includes('settings') && settings !== undefined) callback({ settings }) return () => {} }, + // No tasks/agents services: the tasks routes degrade to a 503. + get: () => undefined, } apply(ctx as never) return routes.find(route => route.path === '/sidebar/api')! @@ -647,6 +655,8 @@ describe('agent terminal tool gating', () => { if (deps.includes('settings')) callback({ settings }) return () => {} }, + // No tasks/agents services: the tasks routes degrade to a 503. + get: () => undefined, } apply(ctx as never) // Default off: no tools are registered even though the settings service is mounted. diff --git a/tests/subagent-tasks-view.spec.tsx b/tests/subagent-tasks-view.spec.tsx new file mode 100644 index 0000000..b288804 --- /dev/null +++ b/tests/subagent-tasks-view.spec.tsx @@ -0,0 +1,267 @@ +/** + * Subagent page tests for the background-task section: rows render from the + * `tasksBySession` mirror, clicking a row peeks its output through + * `tasks.output` with the OWNER session scope, the kill button needs a + * two-click confirm, settled rows offer no kill, and the output panel never + * polls while the page is hidden. + */ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createElement, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import { SubagentView } from '../src/client/SubagentView.tsx' +import type { Context, SidebarSessionList } from '../src/context-types.ts' + +/** A subscribable sessions-list snapshot (mirror of the runtime list feed). */ +function makeStore(initial: SidebarSessionList) { + let snapshot = initial + const listeners = new Set<() => void>() + return { + getSnapshot: () => snapshot, + subscribe: (fn: () => void) => { + listeners.add(fn) + return () => { listeners.delete(fn) } + }, + set(next: SidebarSessionList) { + snapshot = next + for (const fn of [...listeners]) fn() + }, + } +} + +type Store = ReturnType + +/** The client context face SubagentView touches (history stub; everything else inert). */ +function makeCtx(store: Store): Context { + return { + sessions: { + list: store, + setSubagentCatalogOpen: () => {}, + openSubagent: () => {}, + open: () => {}, + refreshSubagents: async () => {}, + }, + connection: { + api: { + subagents: { + history: async () => ({ result: { ok: true, value: { events: [], hasMore: false } } }), + }, + }, + }, + } as unknown as Context +} + +/** Render `node` into a detached body container under React's act(). */ +function mount(node: ReactNode): { container: HTMLDivElement; unmount: () => void } { + const container = document.createElement('div') + document.body.append(container) + const root: Root = createRoot(container) + act(() => { root.render(node) }) + const unmount = (): void => { + act(() => { root.unmount() }) + container.remove() + } + return { container, unmount } +} + +const outputCalls: Array<{ sessionId: string; id: string }> = [] +const killCalls: Array<{ sessionId: string; id: string }> = [] + +function jsonResponse(value: unknown): Response { + return { ok: true, status: 200, json: async () => value } as unknown as Response +} + +function baseSnapshot(): SidebarSessionList { + return { + current: 'root', + byId: { + root: { id: 'root', displayTitle: '主会话', running: true }, + child: { id: 'child', displayTitle: '子代理', origin: 'subagent', parentId: 'root', running: true }, + }, + subagentsByParent: {}, + tasksBySession: { + root: [ + { id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }, + ], + child: [ + { id: 'bash-2', kind: 'bash', label: 'echo hi', status: 'completed', startedAt: 2_000, finishedAt: 3_000 }, + ], + }, + } +} + +beforeEach(() => { + outputCalls.length = 0 + killCalls.length = 0 + vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => { + const method = String(url).split('/').pop() + const body = JSON.parse(String(init?.body)) as { sessionId: string; id: string } + if (method === 'tasks.output') { + outputCalls.push({ sessionId: body.sessionId, id: body.id }) + return jsonResponse({ ok: true, value: { text: `output-of-${body.id}`, truncated: false, status: 'running' } }) + } + if (method === 'tasks.kill') { + killCalls.push({ sessionId: body.sessionId, id: body.id }) + return jsonResponse({ ok: true, value: { ok: true, outcome: 'requested' } }) + } + throw new Error(`unexpected fetch ${String(url)}`) + }) + Object.defineProperty(globalThis.navigator, 'language', { value: 'zh-CN', configurable: true }) +}) + +afterEach(() => { + vi.unstubAllGlobals() + for (const el of document.querySelectorAll('body > div')) el.remove() +}) + +describe('SubagentView background tasks', () => { + it('renders the tree tasks with status, durations, and owner labels', () => { + const store = makeStore(baseSnapshot()) + const { container, unmount } = mount( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) + const text = container.textContent ?? '' + expect(text).toContain('后台命令') + expect(text).toContain('2 个后台命令 · 1 运行中') + // Both rows of the whole tree, owner-labeled (the tree spans two sessions). + expect(text).toContain('sleep 300') + expect(text).toContain('echo hi') + expect(text).toContain('主会话') + expect(text).toContain('子代理') + // Only the running row offers a kill button. + expect(container.querySelectorAll('button[aria-label="终止"]')).toHaveLength(1) + unmount() + }) + + it('renders nothing task-related when the mirror is empty', () => { + const store = makeStore({ current: 'root', byId: { root: { id: 'root', displayTitle: '主会话' } }, subagentsByParent: {}, tasksBySession: {} }) + const { container, unmount } = mount( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) + expect(container.textContent).not.toContain('后台命令') + unmount() + }) + + it('survives the mirror emptying while mounted (hook-order regression #300)', async () => { + const store = makeStore(baseSnapshot()) + const { container, unmount } = mount( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) + expect(container.textContent).toContain('sleep 300') + // The mirror empties (all tasks settled and dropped): the section must + // vanish WITHOUT reordering hooks — a hook below the empty-state return + // would crash React with "Rendered fewer hooks than expected" (the + // minified #300 the sidebar boundary surfaces with a retry button). + await act(async () => { store.set({ ...baseSnapshot(), tasksBySession: {} }) }) + expect(container.textContent).not.toContain('后台命令') + // And returning tasks must work too, with the same hook order. + await act(async () => { store.set(baseSnapshot()) }) + expect(container.textContent).toContain('sleep 300') + unmount() + }) + + it('shows the selected task output in the bottom dock, closeable', async () => { + const store = makeStore(baseSnapshot()) + const { container, unmount } = mount( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) + const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement + await act(async () => { row.click() }) + // The peek request carries the OWNER session (the fence compares it). + expect(outputCalls).toEqual([{ sessionId: 'root', id: 'bash-1' }]) + expect(container.textContent).toContain('output-of-bash-1') + // Exactly one dock region exists (never one per row). + expect(container.querySelectorAll('[role="region"]')).toHaveLength(1) + // The selected row is marked, and the close button dismisses the dock. + expect(row.getAttribute('aria-pressed')).toBe('true') + const close = container.querySelector('button[aria-label="关闭"]') as HTMLButtonElement + await act(async () => { close.click() }) + expect(container.textContent).not.toContain('output-of-bash-1') + expect(container.querySelectorAll('[role="region"]')).toHaveLength(0) + unmount() + }) + + it('switches the single dock between selected rows', async () => { + const store = makeStore(baseSnapshot()) + const { container, unmount } = mount( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) + const first = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement + await act(async () => { first.click() }) + expect(container.textContent).toContain('output-of-bash-1') + const second = container.querySelector('button[aria-label*="echo hi"]') as HTMLButtonElement + await act(async () => { second.click() }) + // One dock, now fed by the second task (its owner session scopes the peek). + expect(outputCalls).toEqual([ + { sessionId: 'root', id: 'bash-1' }, + { sessionId: 'child', id: 'bash-2' }, + ]) + expect(container.querySelectorAll('[role="region"]')).toHaveLength(1) + expect(container.textContent).not.toContain('output-of-bash-1') + expect(container.textContent).toContain('output-of-bash-2') + unmount() + }) + + it('stays compact and functional with many tasks', async () => { + const many = Array.from({ length: 60 }, (_, index) => ({ + id: `bash-${index + 10}`, + kind: 'bash' as const, + label: `bulk cmd ${index}`, + status: index % 2 === 0 ? ('running' as const) : ('completed' as const), + startedAt: 1_000 + index, + ...(index % 2 === 0 ? {} : { finishedAt: 2_000 + index, detail: 'exit code: 1' }), + })) + const store = makeStore({ + current: 'root', + byId: { root: { id: 'root', displayTitle: '主会话' } }, + subagentsByParent: {}, + tasksBySession: { root: many }, + }) + const { container, unmount } = mount( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) + expect(container.textContent).toContain('60 个后台命令 · 30 运行中') + expect(container.querySelectorAll('button[aria-label*="bulk cmd"]')).toHaveLength(60) + // 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 + await act(async () => { row.click() }) + expect(outputCalls).toEqual([{ sessionId: 'root', id: 'bash-69' }]) + expect(container.textContent).toContain('output-of-bash-69') + unmount() + }) + + it('kills a live task only after the two-click confirm', async () => { + const store = makeStore(baseSnapshot()) + const { container, unmount } = mount( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) + const kill = container.querySelector('button[aria-label="终止"]') as HTMLButtonElement + await act(async () => { kill.click() }) + // First click only arms the confirm — no request leaves the page. + expect(killCalls).toEqual([]) + const confirm = container.querySelector('button[aria-label="再次点击确认终止"]') + expect(confirm).not.toBeNull() + await act(async () => { (confirm as HTMLButtonElement).click() }) + expect(killCalls).toEqual([{ sessionId: 'root', id: 'bash-1' }]) + unmount() + }) + + it('does not poll the output while the page is hidden', async () => { + vi.useFakeTimers() + try { + const store = makeStore(baseSnapshot()) + const { container, unmount } = mount( + createElement(SubagentView, { sessionId: 'root', active: false, ctx: makeCtx(store) }), + ) + const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement + await act(async () => { row.click() }) + await act(async () => { await vi.advanceTimersByTimeAsync(10_000) }) + // One peek for the initial load; the 2s poll never ran while inactive. + expect(outputCalls).toHaveLength(1) + unmount() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/tests/subagent-tasks.spec.ts b/tests/subagent-tasks.spec.ts new file mode 100644 index 0000000..59189d7 --- /dev/null +++ b/tests/subagent-tasks.spec.ts @@ -0,0 +1,133 @@ +/** + * 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, + formatTaskDuration, + isTaskLive, + orderTasks, + taskDotState, + taskStatusLabel, + treeSessionIds, +} from '../src/client/subagent-tasks.ts' +import type { SidebarSessionSummary, SidebarTaskStatus, SidebarTaskView } from '../src/context-types.ts' + +/** The translator stub: renders duration templates like the real locale copy. */ +const templates: Record = { + taskDurationSeconds: '{seconds}秒', + taskDurationMinutes: '{minutes}分{seconds}秒', + taskDurationHours: '{hours}小时{minutes}分', +} +const t = (key: string, params?: Record): 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 { + return { id, displayTitle: `title-${id}`, ...over } +} + +function task(id: string, over: Partial = {}): 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秒') + }) +}) diff --git a/tests/tasks-routes.spec.ts b/tests/tasks-routes.spec.ts new file mode 100644 index 0000000..3562f89 --- /dev/null +++ b/tests/tasks-routes.spec.ts @@ -0,0 +1,107 @@ +/** + * Host route tests for the background-task API ('tasks.output' / 'tasks.kill'): + * caller resolution from the requested session, the non-consuming peek + * contract (capped, never reported), kill semantics, and the optional-service + * downgrade when the deployment lacks the tasks registry. + */ +import { describe, expect, it, vi } from 'vitest' +import { buildTasksApi } from '../src/tasks-routes.ts' +import { SidebarError } from '../src/wire.ts' +import type { Context } from '../src/context-types.ts' + +/** A context whose `get` serves only the tasks/agents faces. */ +function ctxWith(tasks: unknown, agents: unknown): Context { + return { + get: (key: string) => (key === 'tasks' ? tasks : key === 'agents' ? agents : undefined), + } as unknown as Context +} + +/** A stub live agent (the fence compares `id` only). */ +const agent = (id: string) => ({ id, session: { header: { cwd: '/p' } } }) + +describe('tasks.output route', () => { + it('peeks the full output with the live caller of the requested session', () => { + const tasks = { + peek: vi.fn(() => ({ text: 'line1\nline2\n', snapshot: { status: 'running' } })), + } + const agents = { get: vi.fn((id: string) => agent(id)) } + const api = buildTasksApi(ctxWith(tasks, agents), 512 * 1024)! + const value = api.output({ sessionId: 's1', id: 'bash-1' }) + + expect(value).toEqual({ text: 'line1\nline2\n', truncated: false, status: 'running' }) + // The caller is the live agent of the OWNING session, and the registry + // call is a peek (non-consuming by construction of the seam contract). + expect(agents.get).toHaveBeenCalledWith('s1') + expect(tasks.peek).toHaveBeenCalledWith('bash-1', agent('s1')) + }) + + it('carries the terminal detail and caps oversized output with the truncated flag', () => { + const tasks = { + peek: vi.fn(() => ({ + text: 'x'.repeat(10_000), + snapshot: { status: 'completed', detail: 'exit code: 0' }, + })), + } + const api = buildTasksApi(ctxWith(tasks, undefined), 100)! + const value = api.output({ sessionId: 's1', id: 'bash-1' }) + + expect(value.text).toBe('x'.repeat(100)) + expect(value.truncated).toBe(true) + expect(value.status).toBe('completed') + expect(value.detail).toBe('exit code: 0') + }) + + it('maps registry refusals to a 404 task-error (unknown/foreign ids stay indistinguishable)', () => { + const tasks = { peek: vi.fn(() => { throw new Error('task bash-9 belongs to another session') }) } + const api = buildTasksApi(ctxWith(tasks, {}), 100)! + expect(() => api.output({ sessionId: 's1', id: 'bash-9' })).toThrowError( + expect.objectContaining>({ code: 'task-error', status: 404 }), + ) + }) + + it('rejects a missing sessionId or id as bad-request', () => { + const tasks = { peek: vi.fn() } + const api = buildTasksApi(ctxWith(tasks, {}), 100)! + expect(() => api.output({ id: 'bash-1' })).toThrowError(expect.objectContaining>({ code: 'bad-request' })) + expect(() => api.output({ sessionId: 's1' })).toThrowError(expect.objectContaining>({ code: 'bad-request' })) + }) +}) + +describe('tasks.kill route', () => { + it('kills with the forwarded reason and the live caller', () => { + const tasks = { kill: vi.fn(() => 'requested' as const) } + const agents = { get: vi.fn((id: string) => agent(id)) } + const api = buildTasksApi(ctxWith(tasks, agents), 100)! + expect(api.kill({ sessionId: 's1', id: 'bash-1', reason: 'user pressed stop' })) + .toEqual({ ok: true, outcome: 'requested' }) + expect(tasks.kill).toHaveBeenCalledWith('bash-1', agent('s1'), 'user pressed stop') + }) + + it('defaults the reason when none is supplied', () => { + const tasks = { kill: vi.fn(() => 'already-finished' as const) } + const api = buildTasksApi(ctxWith(tasks, undefined), 100)! + 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') + }) + + it('maps registry refusals to a 404 task-error', () => { + const tasks = { kill: vi.fn(() => { throw new Error('unknown task bash-9') }) } + const api = buildTasksApi(ctxWith(tasks, {}), 100)! + expect(() => api.kill({ sessionId: 's1', id: 'bash-9' })).toThrowError( + expect.objectContaining>({ code: 'task-error', status: 404 }), + ) + }) +}) + +describe('optional-service downgrade', () => { + it('returns undefined (the caller maps to a 503) when ctx.tasks is absent', () => { + expect(buildTasksApi(ctxWith(undefined, {}), 100)).toBeUndefined() + }) + + it('resolves the caller as undefined when the agents registry is absent', () => { + const tasks = { peek: vi.fn(() => ({ text: '', snapshot: { status: 'running' } })), kill: vi.fn(() => 'requested' as const) } + const api = buildTasksApi(ctxWith(tasks, undefined), 100)! + api.output({ sessionId: 's1', id: 'bash-1' }) + expect(tasks.peek).toHaveBeenCalledWith('bash-1', undefined) + }) +})