From 1bfabbbfc23fb2fea7b121748ebd6ab99b010ae9 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Thu, 13 Aug 2026 09:06:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=80=82=E9=85=8D=20DSH=20=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E5=A5=91=E7=BA=A6=E9=87=8D=E5=91=BD=E5=90=8D=EF=BC=88?= =?UTF-8?q?jobs/terminal/webServer=EF=BC=89=E5=B9=B6=E5=A4=84=E7=90=86=20S?= =?UTF-8?q?ession=20log=20=E6=8C=89=E9=92=AE=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tasks→jobs:ctx.jobs / job_output / job_id / session/jobs / jobsBySession / Job* 词汇 - httpServer→webServer(inject + augmentation + register/registerUpgrade) - autoOpenTasks→autoOpenJobs 偏好键 - 右上角开关簇回到角落,注入 CSS 挤开 header 的「Session log」胶囊 --- AGENTS.md | 2 +- README.md | 4 +- src/bundle-route.ts | 2 +- src/client/Sidebar.tsx | 36 ++- src/client/SubagentView.module.css | 74 +++---- src/client/SubagentView.tsx | 206 +++++++++--------- src/client/api.ts | 28 +-- src/client/builtins/tabs.tsx | 8 +- src/client/icons.tsx | 2 +- src/client/layout.css | 10 + src/client/locales.ts | 88 ++++---- src/client/prefs.ts | 6 +- .../{subagent-tasks.ts => subagent-jobs.ts} | 106 ++++----- src/config.ts | 2 +- src/context-types.ts | 58 ++--- src/index.ts | 38 ++-- src/invariant.ts | 2 +- src/{tasks-routes.ts => jobs-routes.ts} | 100 ++++----- src/prefs-shared.ts | 8 +- src/pty-manager.ts | 2 +- src/wire.ts | 2 +- tests/builtins.spec.ts | 2 +- ...sks-routes.spec.ts => jobs-routes.spec.ts} | 130 +++++------ tests/plugin-shape.spec.ts | 8 +- tests/smoke.spec.ts | 22 +- ...w.spec.tsx => subagent-jobs-view.spec.tsx} | 56 ++--- tests/subagent-jobs.spec.ts | 165 ++++++++++++++ tests/subagent-tasks.spec.ts | 165 -------------- tests/unit.spec.ts | 20 +- 29 files changed, 687 insertions(+), 665 deletions(-) rename src/client/{subagent-tasks.ts => subagent-jobs.ts} (50%) rename src/{tasks-routes.ts => jobs-routes.ts} (70%) rename tests/{tasks-routes.spec.ts => jobs-routes.spec.ts} (53%) rename tests/{subagent-tasks-view.spec.tsx => subagent-jobs-view.spec.tsx} (86%) create mode 100644 tests/subagent-jobs.spec.ts delete mode 100644 tests/subagent-tasks.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 1a7a25f..7826016 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context - **禁止修改 DeepSeek Harness (DSH) 源码**:对官方源码 checkout(`~/.dsh/source/current`)零写入——不得改 harness 包、不得把 harness 改动提交到它的分支。 - **挂载只走 `cordis.patch.yml` + profile 机制**(`~/.dsh/profiles//`),插件永远作为独立包被 profile 引用,不反向侵入 DSH。 -- 需要 harness 没有的能力时,用 DSH **现成的只读/公开 API** 或插件自有路由实现(参考 §7 的 `tasks.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 DSH。 +- 需要 harness 没有的能力时,用 DSH **现成的只读/公开 API** 或插件自有路由实现(参考 §7 的 `jobs.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 DSH。 --- diff --git a/README.md b/README.md index c363584..129d2ba 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > 一个插件,一套完整工作台: > -> 文件管理、编辑预览、内嵌浏览器、真实终端、Git 面板、任务管理页——**右侧栏 + 底部面板双工作台**,一个插件全部搞定。 +> 文件管理、编辑预览、内嵌浏览器、真实终端、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、懒加载历史、右键暂存/放弃/提交/还原/捡取 -- **🧩 任务管理页**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开;**同页显示后台命令**(当前树全部任务,bash/pwsh 类型徽标 + 退出码,点击查看实时输出——自动跟随底部、非消费 peek,不干扰模型的 `task_output`;两击确认可强制终止) +- **🧩 后台任务页**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开;**同页显示后台任务**(当前树全部后台任务,bash/pwsh 类型徽标 + 退出码,点击查看实时输出——自动跟随底部、非消费 peek,不干扰模型的 `job_output`;两击确认可强制终止) - **🪟 底部面板**:独立的第二个工作台(与右侧栏同类的标签页),只挤占中间 Agent 输出区、不覆盖左右侧边栏;**首次展开自动开一个新终端**(终端卡片二级设置可关);右上角 x 一键折叠 - **📱 移动端**:视口 < 768px(真正的移动端宽度,不对齐宿主 1024 断点)时只显示右侧栏——进入窄屏时底部面板的标签页**直接并入右侧栏标签条**,右上角只剩一枚开关,面板为全宽抽屉;新会话默认收起,聊天里点文件/外链自动展开,不挤压对话区 - **🔧 分栏工作台**:拖 Tab 拆分/合并分栏(可**跨面板拖 Tab**)、分隔线调比例;右上角持久按钮簇(底栏 + 侧拉 glyph)折叠/展开两个面板;两面板共享拐角双向拖动调节尺寸,拖动 rAF 直写 DOM 保持流畅 diff --git a/src/bundle-route.ts b/src/bundle-route.ts index 16aab38..4c5e772 100644 --- a/src/bundle-route.ts +++ b/src/bundle-route.ts @@ -122,7 +122,7 @@ export function createBundleRouteHandler( /** Register the /sidebar/bundle route (disposed with the fiber). */ export function registerBundleRoute(ctx: Context, fence: (req: IncomingMessage) => boolean): () => void { - return ctx.httpServer.register({ + return ctx.webServer.register({ kind: 'prefix', path: '/sidebar/bundle', handler: createBundleRouteHandler(fence), diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 503c65a..b8bf8a6 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -45,7 +45,7 @@ import type { TabDragPayload } from './TabBar.tsx' import { relativeTo } from './paths.ts' import { OrphanedTab } from './OrphanedTab.tsx' import { detectNewDirectSubagent } from './subagent-detect.ts' -import { detectNewTask } from './subagent-tasks.ts' +import { detectNewJob } from './subagent-jobs.ts' import { t } from './locales.ts' import { api, type SessionScope } from './api.ts' import css from './sidebar.module.css' @@ -142,6 +142,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { const sessionId = snapshot.sessionId 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 * while narrow (mount, session switch, or a desktop→narrow transition), @@ -253,21 +265,21 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { }, [sessionList, sessionId, store, ctx]) /** - * Task auto-activation: the moment a NEW background task appears for the - * current conversation (a task id the previous snapshot lacked), the - * auto-open pref is on, and the Tasks tab type is enabled, open the panel - * (if collapsed) and focus the Tasks page. Unlike the subagent trigger - * (0 → N only), ANY new task id triggers: the agent may start several - * tasks in one session, and each should surface. A fresh page load never + * Job auto-activation: the moment a NEW background job appears for the + * current conversation (a job id the previous snapshot lacked), the + * auto-open pref is on, and the Jobs tab type is enabled, open the panel + * (if collapsed) and focus the Jobs page. Unlike the subagent trigger + * (0 → N only), ANY new job id triggers: the agent may start several + * jobs in one session, and each should surface. A fresh page load never * triggers — its baseline starts at the current snapshot. */ - const taskBaselineRef = useRef(undefined) + const jobBaselineRef = useRef(undefined) useEffect(() => { - const prev = taskBaselineRef.current - taskBaselineRef.current = sessionList + const prev = jobBaselineRef.current + jobBaselineRef.current = sessionList if (sessionId === undefined || prev === undefined) return - if (!detectNewTask(prev, sessionList, sessionId)) return - if (!store.getPrefs().autoOpenTasks) return + if (!detectNewJob(prev, sessionList, sessionId)) return + if (!store.getPrefs().autoOpenJobs) return if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return store.reduce(s => s.panelOpen ? s : togglePanel(s)) store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id })) diff --git a/src/client/SubagentView.module.css b/src/client/SubagentView.module.css index 0354475..14ddd9b 100644 --- a/src/client/SubagentView.module.css +++ b/src/client/SubagentView.module.css @@ -274,15 +274,15 @@ 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; border-top: 1px solid var(--dsw-alias-border-l2); padding-top: 8px; } -.tasksHeader { +.jobsHeader { display: flex; align-items: center; gap: 8px; @@ -290,7 +290,7 @@ padding: 0 2px; } -.tasksTitle { +.jobsTitle { flex: 1; min-width: 0; font: var(--dsw-font-xxxs-strong-11); @@ -300,13 +300,13 @@ white-space: nowrap; } -.tasksCount { +.jobsCount { flex: none; font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary); } -.tasksList { +.jobsList { display: flex; flex-direction: column; gap: 2px; @@ -315,30 +315,30 @@ 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). */ -.tasksRow { +.jobsRow { display: flex; align-items: center; gap: 4px; border-radius: 8px; } -.tasksRow:hover { +.jobsRow:hover { background: var(--dsw-alias-interactive-bg-hover); } -.tasksRowSettled { +.jobsRowSettled { opacity: 0.8; } /* The row whose output is shown in the bottom dock. */ -.tasksRowSelected, -.tasksRowSelected:hover { +.jobsRowSelected, +.jobsRowSelected:hover { background: var(--dsw-alias-interactive-bg-active); } -.tasksRowMain { +.jobsRowMain { flex: 1; display: flex; align-items: flex-start; @@ -355,15 +355,15 @@ outline: none; } -.tasksRowMain:focus-visible { +.jobsRowMain:focus-visible { background: var(--dsw-alias-interactive-bg-hover); } -.tasksDot { +.jobsDot { margin-top: 5px; } -.tasksContent { +.jobsContent { display: flex; flex-direction: column; min-width: 0; @@ -372,14 +372,14 @@ /* The command line: a kind badge followed by the command itself in the code face, like the terminal's live lines. */ -.tasksLabelLine { +.jobsLabelLine { display: flex; align-items: center; gap: 6px; min-width: 0; } -.tasksKind { +.jobsKind { flex: none; max-width: 90px; overflow: hidden; @@ -393,7 +393,7 @@ color: var(--dsw-alias-label-tertiary); } -.tasksLabel { +.jobsLabel { flex: 1; min-width: 0; overflow: hidden; @@ -405,7 +405,7 @@ color: var(--dsw-alias-label-primary); } -.tasksSecondary { +.jobsSecondary { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -413,7 +413,7 @@ color: var(--dsw-alias-label-tertiary); } -.tasksKill { +.jobsKill { flex: none; display: inline-flex; align-items: center; @@ -428,14 +428,14 @@ cursor: pointer; } -.tasksKill:hover { +.jobsKill: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 { +.jobsKillArmed, +.jobsKillArmed:hover { width: auto; height: 20px; padding: 0 8px; @@ -445,12 +445,12 @@ white-space: nowrap; } -.tasksKill:disabled { +.jobsKill:disabled { opacity: 0.5; cursor: default; } -.tasksKillError { +.jobsKillError { flex: none; margin-right: 4px; font: var(--dsw-font-xxxs-11); @@ -460,9 +460,9 @@ /* ── 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 + edge while the job list scrolls above it; a solid background keeps the tree from showing through. */ -.tasksPane { +.jobsPane { position: sticky; bottom: 0; z-index: 1; @@ -474,7 +474,7 @@ overflow: hidden; } -.tasksPaneHeader { +.jobsPaneHeader { display: flex; align-items: center; gap: 6px; @@ -483,11 +483,11 @@ border-bottom: 1px solid var(--dsw-alias-border-l1); } -.tasksPaneDot { +.jobsPaneDot { flex: none; } -.tasksPaneLabel { +.jobsPaneLabel { flex: 1; min-width: 0; overflow: hidden; @@ -499,13 +499,13 @@ color: var(--dsw-alias-label-primary); } -.tasksPaneStatus { +.jobsPaneStatus { flex: none; font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary); } -.tasksPaneClose { +.jobsPaneClose { flex: none; display: inline-flex; align-items: center; @@ -519,11 +519,11 @@ cursor: pointer; } -.tasksPaneClose:hover { +.jobsPaneClose:hover { background: var(--dsw-alias-interactive-bg-hover); } -.tasksPanePre { +.jobsPanePre { margin: 0; max-height: 200px; overflow: auto; @@ -536,12 +536,12 @@ word-break: break-word; } -.tasksPaneHint { +.jobsPaneHint { padding: 8px 10px; font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary); } -.tasksPaneError { +.jobsPaneError { color: var(--dsw-alias-state-error-primary); } diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx index cd8e8b7..ae139a4 100644 --- a/src/client/SubagentView.tsx +++ b/src/client/SubagentView.tsx @@ -33,7 +33,7 @@ import type { SidebarSubagentCatalog, SidebarSubagentChildEntry, SidebarSubagentDiagnosticEntry, - SidebarTaskView, + SidebarJobView, } from '../context-types.ts' import { collectBranchIds, @@ -42,15 +42,15 @@ import { } 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' + collectTreeJobs, + formatJobDuration, + isJobLive, + orderJobs, + jobDotState, + jobStatusLabel, + type TreeJob, +} from './subagent-jobs.ts' +import { api, type JobOutputResult } from './api.ts' import { IconStopOutline16 } from './icons.tsx' import { t } from './locales.ts' import css from './SubagentView.module.css' @@ -59,10 +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 +/** Refresh cadence of an expanded job-output panel while its job runs. */ +const JOB_POLL_MS = 2000 /** 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). */ function directChildren( @@ -358,24 +358,24 @@ 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 output as + * The shared output dock of the jobs section: ONE pane at the bottom of the + * 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 - * log), refreshed every {@link TASK_POLL_MS} while the task runs and the - * page is visible. The model's `task_output` cursor is never touched — the + * log), refreshed every {@link JOB_POLL_MS} while the job runs and 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 - * agent reads the task. A single dock — not a panel per row — keeps the - * task list compact and stable when many tasks are running. + * agent reads the job. A single dock — not a panel per row — keeps the + * job list compact and stable when many jobs are running. */ -function TaskOutputPane(props: { +function JobOutputPane(props: { ownerSessionId: string - task: SidebarTaskView + job: SidebarJobView /** 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 { ownerSessionId, job, active, onClose } = props + const [state, setState] = useState<'loading' | JobOutputResult | 'error'>('loading') const controllerRef = useRef(undefined) const preRef = useRef(null) @@ -384,44 +384,44 @@ function TaskOutputPane(props: { const controller = new AbortController() controllerRef.current = controller 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) } 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]) + }, [ownerSessionId, job.id]) useEffect(() => { void load() - if (!active || !isTaskLive(task)) return - const timer = window.setInterval(() => { void load() }, TASK_POLL_MS) + if (!active || !isJobLive(job)) return + const timer = window.setInterval(() => { void load() }, JOB_POLL_MS) return () => { window.clearInterval(timer) } - }, [load, active, task.status]) + }, [load, active, job.status]) 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. 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 if (pre !== null) pre.scrollTop = pre.scrollHeight - }, [state, task.status]) + }, [state, job.status]) return ( -
-
- - {task.label} - - {taskStatusLabel(task.status, t)} - {task.detail !== undefined && task.detail !== '' ? ` · ${task.detail}` : ''} +
+
+ + {job.label} + + {jobStatusLabel(job.status, t)} + {job.detail !== undefined && job.detail !== '' ? ` · ${job.detail}` : ''}
- {state === 'loading' &&
{t('loading')}
} + {state === 'loading' &&
{t('loading')}
} {state === 'error' && ( -
{t('taskOutputError')}
+
{t('jobOutputError')}
)} {typeof state === 'object' && ( <> {state.text.length > 0 - ?
{state.text}
+ ?
{state.text}
: state.read - ?
{t('taskNoOutput')}
- :
{t('taskNotReadYet')}
} - {state.truncated &&
{t('taskOutputTruncated')}
} + ?
{t('jobNoOutput')}
+ :
{t('jobNotReadYet')}
} + {state.truncated &&
{t('jobOutputTruncated')}
} )}
@@ -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 - * `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 * 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'] - tasksBySession: SidebarSessionList['tasksBySession'] + jobsBySession: SidebarSessionList['jobsBySession'] rootId: string | undefined /** The page is visible (active tab + open panel): skip polling otherwise. */ active: boolean }) { - const { byId, tasksBySession, rootId, active } = props + const { byId, jobsBySession, rootId, active } = props const rows = useMemo( - () => orderTasks(collectTreeTasks(byId, tasksBySession, rootId)), - [byId, tasksBySession, rootId], + () => orderJobs(collectTreeJobs(byId, jobsBySession, rootId)), + [byId, jobsBySession, rootId], ) const [selectedId, setSelectedId] = useState(undefined) const [armedId, setArmedId] = useState(undefined) @@ -475,12 +475,12 @@ function TasksSection(props: { const [now, setNow] = useState(() => Date.now()) 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], ) 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], ) const multiOwner = useMemo( @@ -491,7 +491,7 @@ function TasksSection(props: { // 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) + const timer = window.setTimeout(() => { setArmedId(undefined) }, JOB_KILL_ARM_MS) return () => { window.clearTimeout(timer) } }, [armedId]) @@ -502,7 +502,7 @@ function TasksSection(props: { return () => { window.clearInterval(timer) } }, [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. useEffect(() => { 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 // 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) + const kill = useCallback(async (row: TreeJob): Promise => { + setKillingId(row.job.id) setKillErrorId(undefined) try { - await api.taskKill({ sessionId: row.ownerSessionId }, row.task.id) + await api.jobKill({ sessionId: row.ownerSessionId }, row.job.id) } catch { - setKillErrorId(row.task.id) + setKillErrorId(row.job.id) } finally { setKillingId(undefined) setArmedId(undefined) @@ -527,84 +527,84 @@ function TasksSection(props: { if (rows.length === 0) return null const countLabel = liveCount > 0 - ? t('tasksCountRunning', { count: rows.length, running: liveCount }) - : t('tasksCount', { count: rows.length }) + ? t('jobsCountRunning', { count: rows.length, running: liveCount }) + : t('jobsCount', { count: rows.length }) return ( <> -
-
- {t('tasks')} - {countLabel} +
+
+ {t('jobs')} + {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 { job } = row + const live = isJobLive(job) + const selected = selectedId === job.id + const armed = armedId === job.id + const killing = killingId === job.id + const killFailed = killErrorId === job.id const elapsed = live - ? now - task.startedAt - : (task.finishedAt ?? task.startedAt) - task.startedAt + ? now - job.startedAt + : (job.finishedAt ?? job.startedAt) - job.startedAt const secondary = [ ...(multiOwner ? [row.ownerTitle] : []), - taskStatusLabel(task.status, t), - ...(task.detail !== undefined && task.detail !== '' ? [task.detail] : []), - formatTaskDuration(elapsed, t), + jobStatusLabel(job.status, t), + ...(job.detail !== undefined && job.detail !== '' ? [job.detail] : []), + formatJobDuration(elapsed, t), ].filter(Boolean).join(' · ') return (
    • - {task.status === 'running' && ( + {job.status === 'running' && ( )} - {killFailed && {t('taskKillError')}} + {killFailed && {t('jobKillError')}}
    • ) })}
{selectedRow !== undefined && ( - { setSelectedId(undefined) }} /> @@ -854,9 +854,9 @@ export function SubagentView(props: {
)}
- diff --git a/src/client/api.ts b/src/client/api.ts index 22db5fc..619b60a 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -61,17 +61,17 @@ export interface FsTextResult { kind: 'text'; content: string; truncated: boolea 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 - * task (replayed from the owner session's event log — the model's - * task_output cursor is never touched, so the pane can never steal the + * One jobs.output response: the output the MODEL has read so far for the + * job (replayed from the owner session's event log — the model's + * job_output cursor is never touched, so the pane can never steal the * 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 /** True when the host capped the text at its output limit. */ truncated: boolean - /** Whether the model has read the task at least once. */ + /** Whether the model has read the job at least once. */ read: boolean } @@ -161,15 +161,15 @@ export const api = { agentPtyClose: (uuid: string) => call<{ ok: true }>('agent-pty.close', { uuid }), /** - * The output the model has read so far for one background task (replayed - * from the owner session's event log — never the model's task_output - * cursor). The scope MUST be the task's OWNER session. + * The output the model has read so far for one background job (replayed + * from the owner session's event log — never the model's job_output + * cursor). The scope MUST be the job's OWNER session. */ - 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, { + jobOutput: (scope: SessionScope, id: string, signal?: AbortSignal) => + call('jobs.output', scopePayload(scope, { id }), signal), + /** Request cancellation of one background job (live jobs flip to stopping). */ + jobKill: (scope: SessionScope, id: string, reason?: string) => + call<{ ok: true; outcome: 'requested' | 'already-finished' }>('jobs.kill', scopePayload(scope, { id, ...(reason !== undefined ? { reason } : {}), })), diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx index 85cf32f..dac51c6 100644 --- a/src/client/builtins/tabs.tsx +++ b/src/client/builtins/tabs.tsx @@ -110,16 +110,16 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] { order: 30, single: true, // 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: { toggles: [{ key: 'autoOpenSubagent', title: () => t('settingsSubagentTitle'), desc: () => t('settingsSubagentDesc'), }, { - key: 'autoOpenTasks', - title: () => t('settingsTasksTitle'), - desc: () => t('settingsTasksDesc'), + key: 'autoOpenJobs', + title: () => t('settingsJobsTitle'), + desc: () => t('settingsJobsDesc'), }], }, component: ({ ctx, scope, visible, onSubagentJump }) => ( diff --git a/src/client/icons.tsx b/src/client/icons.tsx index ae16e12..c761e18 100644 --- a/src/client/icons.tsx +++ b/src/client/icons.tsx @@ -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. */ export const IconStopOutline16 = ({ size = 16, className }: IconProps) => ( diff --git a/src/client/layout.css b/src/client/layout.css index 3ead995..04351e6 100644 --- a/src/client/layout.css +++ b/src/client/layout.css @@ -31,6 +31,16 @@ 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 > div > div:nth-child(2) { transition: none; diff --git a/src/client/locales.ts b/src/client/locales.ts index 2f6e906..49cd471 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -127,8 +127,8 @@ export const zh = { zoomHint: 'Alt + 滚轮', settingsSubagentTitle: '检测到子代理时自动展开任务管理页', settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开任务管理页;关闭后需手动打开', - settingsTasksTitle: '有新后台任务时自动展开任务管理页', - settingsTasksDesc: '当前会话出现新的后台任务时,自动展开侧边栏并打开任务管理页(每个新任务都会触发);关闭后需手动打开', + settingsJobsTitle: '有新后台任务时自动展开后台任务页', + settingsJobsDesc: '当前会话出现新的后台任务时,自动展开侧边栏并打开后台任务页(每个新任务都会触发);关闭后需手动打开', settingsToolsTitle: '为模型注入终端工具', settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)', settingsBottomTerminalTitle: '底部面板首次展开自动开终端', @@ -188,26 +188,26 @@ export 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: '暂无输出', - taskNotReadYet: '等待模型读取该任务的输出(模型执行 task_output 后,输出会显示在这里)', - taskOutputTruncated: '输出过长,已截断显示', - taskOutputError: '输出读取失败', - taskKill: '终止', - taskKillConfirm: '再次点击确认终止', - taskKillError: '终止失败', + jobs: '后台任务', + jobsCount: '{count} 个后台任务', + jobsCountRunning: '{count} 个后台任务 · {running} 运行中', + jobStatusRunning: '运行中', + jobStatusStopping: '终止中', + jobStatusCompleted: '已完成', + jobStatusKilled: '已终止', + jobStatusFailed: '失败', + jobDurationSeconds: '{seconds} 秒', + jobDurationMinutes: '{minutes} 分 {seconds} 秒', + jobDurationHours: '{hours} 小时 {minutes} 分', + jobViewOutput: '查看输出', + jobHideOutput: '收起输出', + jobNoOutput: '暂无输出', + jobNotReadYet: '等待模型读取该任务的输出(模型执行 job_output 后,输出会显示在这里)', + jobOutputTruncated: '输出过长,已截断显示', + jobOutputError: '输出读取失败', + jobKill: '终止', + jobKillConfirm: '再次点击确认终止', + jobKillError: '终止失败', } /** The en dictionary (key-set-equal to zh, enforced by the type annotation). */ @@ -328,8 +328,8 @@ export const en: Record = { zoomHint: 'Alt + wheel', 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', - settingsTasksTitle: 'Auto-open the Tasks page on a new background task', - 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', + settingsJobsTitle: 'Auto-open the Jobs page on a new background job', + 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', 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', @@ -389,26 +389,26 @@ export 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', - taskNotReadYet: 'Waiting for the model to read this task; its output appears here once the model runs task_output', - taskOutputTruncated: 'Output truncated', - taskOutputError: 'Failed to read output', - taskKill: 'Kill', - taskKillConfirm: 'Click again to confirm kill', - taskKillError: 'Kill failed', + jobs: 'Background jobs', + jobsCount: '{count} background jobs', + jobsCountRunning: '{count} background jobs · {running} running', + jobStatusRunning: 'Running', + jobStatusStopping: 'Stopping', + jobStatusCompleted: 'Completed', + jobStatusKilled: 'Killed', + jobStatusFailed: 'Failed', + jobDurationSeconds: '{seconds}s', + jobDurationMinutes: '{minutes}m {seconds}s', + jobDurationHours: '{hours}h {minutes}m', + jobViewOutput: 'View output', + jobHideOutput: 'Hide output', + jobNoOutput: 'No output yet', + jobNotReadYet: 'Waiting for the model to read this job; its output appears here once the model runs job_output', + jobOutputTruncated: 'Output truncated', + jobOutputError: 'Failed to read output', + jobKill: 'Kill', + jobKillConfirm: 'Click again to confirm kill', + jobKillError: 'Kill failed', } /** diff --git a/src/client/prefs.ts b/src/client/prefs.ts index 72167b8..5006dc9 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -40,9 +40,9 @@ export function parsePrefs(value: unknown): SidebarPrefs { autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean' ? record.autoOpenSubagent : SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent, - autoOpenTasks: typeof record.autoOpenTasks === 'boolean' - ? record.autoOpenTasks - : SIDEBAR_PREFS_DEFAULTS.autoOpenTasks, + autoOpenJobs: typeof record.autoOpenJobs === 'boolean' + ? record.autoOpenJobs + : SIDEBAR_PREFS_DEFAULTS.autoOpenJobs, agentTerminalTools: typeof record.agentTerminalTools === 'boolean' ? record.agentTerminalTools : SIDEBAR_PREFS_DEFAULTS.agentTerminalTools, diff --git a/src/client/subagent-tasks.ts b/src/client/subagent-jobs.ts similarity index 50% rename from src/client/subagent-tasks.ts rename to src/client/subagent-jobs.ts index e1fae63..91eaccf 100644 --- a/src/client/subagent-tasks.ts +++ b/src/client/subagent-jobs.ts @@ -1,29 +1,29 @@ /** - * 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 + * Pure derivations for the Subagent page's background-job section. Kept + * framework-free so the node test environment can unit-test them: the job + * rows arrive through the harness `session/jobs` push mirror + * (`jobsBySession` in the sessions list feed) — nothing here issues * requests, and the row ordering / status mapping mirror the official - * ui-task header list. + * ui-jobs header list. */ import type { SidebarSessionList, SidebarSessionSummary, - SidebarTaskStatus, - SidebarTaskView, + SidebarJobStatus, + SidebarJobView, } 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 { +/** One row of the jobs section: the job plus its owning session's title. */ +export interface TreeJob { ownerSessionId: string ownerTitle: string - task: SidebarTaskView + job: SidebarJobView } -/** Whether the registry still holds the task open (its duration ticks). */ -export function isTaskLive(task: SidebarTaskView): boolean { - return task.status === 'running' || task.status === 'stopping' +/** Whether the registry still holds the job open (its duration ticks). */ +export function isJobLive(job: SidebarJobView): boolean { + 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 * 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. + * jobs section never shows foreign work. */ export function treeSessionIds( byId: SidebarSessionList['byId'], @@ -58,67 +58,67 @@ export function treeSessionIds( } /** - * Whether a NEW background task appeared for one session between two - * consecutive list snapshots (a task id the previous snapshot lacked). - * Unlike the subagent auto-open (0 → N only), ANY new task id triggers: the - * agent may start several tasks over a session, and each new one should - * surface the Tasks page (a fresh page load never triggers — its baseline + * Whether a NEW background job appeared for one session between two + * consecutive list snapshots (a job id the previous snapshot lacked). + * Unlike the subagent auto-open (0 → N only), ANY new job id triggers: the + * agent may start several jobs over a session, and each new one should + * surface the Jobs page (a fresh page load never triggers — its baseline * starts at the current snapshot). */ -export function detectNewTask( +export function detectNewJob( prev: SidebarSessionList, next: SidebarSessionList, sessionId: string, ): boolean { - const prevIds = new Set((prev.tasksBySession?.[sessionId] ?? []).map(task => task.id)) - return (next.tasksBySession?.[sessionId] ?? []).some(task => !prevIds.has(task.id)) + const prevIds = new Set((prev.jobsBySession?.[sessionId] ?? []).map(job => job.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 - * (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'], - tasksBySession: Readonly> | undefined, + jobsBySession: Readonly> | undefined, rootId: string | undefined, -): TreeTask[] { - const rows: TreeTask[] = [] - if (tasksBySession === undefined) return rows +): TreeJob[] { + const rows: TreeJob[] = [] + if (jobsBySession === undefined) return rows for (const sessionId of treeSessionIds(byId, rootId)) { - const tasks = tasksBySession[sessionId] - if (tasks === undefined || tasks.length === 0) continue + const jobs = jobsBySession[sessionId] + if (jobs === undefined || jobs.length === 0) continue 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 } /** * 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. */ -export function orderTasks(rows: readonly TreeTask[]): TreeTask[] { +export function orderJobs(rows: readonly TreeJob[]): TreeJob[] { 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 + const liveLeft = isJobLive(left.job) + if (liveLeft !== isJobLive(right.job)) return liveLeft ? -1 : 1 + if (liveLeft) return left.job.startedAt - right.job.startedAt + const finished = (right.job.finishedAt ?? right.job.startedAt) - (left.job.finishedAt ?? left.job.startedAt) + return finished !== 0 ? finished : left.job.startedAt - right.job.startedAt }) } /** 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 * color: both mean the work ended (or is ending) on request rather than on * its own. */ -export function taskDotState(status: SidebarTaskStatus): TaskDotState { +export function jobDotState(status: SidebarJobStatus): JobDotState { switch (status) { case 'running': return 'ongoing' 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). */ -export function taskStatusLabel( - status: SidebarTaskStatus, +export function jobStatusLabel( + status: SidebarJobStatus, 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') + case 'running': return t('jobStatusRunning') + case 'stopping': return t('jobStatusStopping') + case 'completed': return t('jobStatusCompleted') + case 'killed': return t('jobStatusKilled') + case 'failed': return t('jobStatusFailed') } } /** * 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. */ -export function formatTaskDuration( +export function formatJobDuration( elapsedMs: number, t: (key: CopyKey, params?: Record) => string, ): string { @@ -155,7 +155,7 @@ export function formatTaskDuration( 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 }) + if (hours > 0) return t('jobDurationHours', { hours, minutes }) + if (minutes > 0) return t('jobDurationMinutes', { minutes, seconds }) + return t('jobDurationSeconds', { seconds }) } diff --git a/src/config.ts b/src/config.ts index 687584d..0ddd0a4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -79,7 +79,7 @@ export const PrefsSchema: z = z.object({ openByDefault: z.boolean().default(true), defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT), autoOpenSubagent: z.boolean().default(true), - autoOpenTasks: z.boolean().default(true), + autoOpenJobs: z.boolean().default(true), agentTerminalTools: z.boolean().default(false), bottomPanelAutoTerminal: z.boolean().default(true), interceptOpenPath: z.boolean().default(true), diff --git a/src/context-types.ts b/src/context-types.ts index 6cc4c44..1895bbb 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -6,14 +6,14 @@ * the npm cordis package does not declare the DSH-vendored runtime members * (`ctx.effect`, service properties). The members below mirror the actual * 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 * side the runtime ISessions list feed * - conversation: client side ui-conversation's IConversation (composer * draft), read lazily through `ctx.get` — cross-plugin service reads need * an inject declaration, so the direct property is never typed here * - loader: @cordisjs/plugin-loader (entry options) - * - slots: the client runtime SlotsService + * - slots: the client runtime SlotRegistry * - effect: the DSH-vendored cordis lifecycle helper * Drift from upstream is contained to this file. */ @@ -35,8 +35,8 @@ export interface SidebarWebUpgradeRoute { handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise } -/** The httpServer service face this plugin uses. */ -export interface SidebarHttpServer { +/** The webServer service face this plugin uses. */ +export interface SidebarWebServer { register(route: SidebarWebRoute): () => void registerUpgrade(route: SidebarWebUpgradeRoute): () => void } @@ -53,7 +53,7 @@ export interface SidebarSessionStore { /** * The live session's append-only event log (immutable snapshot; absent * 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[] } | undefined @@ -91,7 +91,7 @@ export interface SidebarSlotsService { register(options: SidebarSlotRegisterOptions, component: unknown): () => void /** * 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. */ inject(key: string, callback: () => () => void): () => void @@ -158,33 +158,33 @@ export interface SidebarHistoryEntry { view?: unknown } -/** Lifecycle status set of one background task (closed wire union). */ -export type SidebarTaskStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed' +/** Lifecycle status set of one background job (closed wire union). */ +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?). */ -export interface SidebarTaskView { - /** Registry-issued `-N` identity, stable for the task's whole life. */ +export interface SidebarJobView { + /** Registry-issued `-N` identity, stable for the job'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 + status: SidebarJobStatus /** Kind-specific status detail ('exit code: 3'), present once supplied. */ detail?: string - /** Epoch ms when the task was registered. */ + /** Epoch ms when the job was registered. */ startedAt: number - /** Epoch ms when the task settled; absent while live. */ + /** Epoch ms when the job settled; absent while live. */ finishedAt?: number } -/** The host tasks registry face the sidebar routes touch (structural mirror of `TaskService`). */ -export interface SidebarTasksService { - /** Request cancellation; throws for an unknown or foreign task. */ +/** The host jobs registry face the sidebar routes touch (structural mirror of `JobRegistry`). */ +export interface SidebarJobsService { + /** Request cancellation; throws for an unknown or foreign job. */ 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. */ subagentsByParent?: Readonly> /** - * 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 - * than the tasks mirror — the sidebar simply shows no task rows. + * than the jobs mirror — the sidebar simply shows no job rows. */ - tasksBySession?: Readonly> + jobsBySession?: Readonly> } /** The client sessions service face (only the list feed is needed). */ @@ -267,7 +267,7 @@ export interface SidebarSessionsService { /** * The client locale service face (mirror of @deepseek-ai/dsh-client-locale's - * LocaleService — only the slices the sidebar touches). The sidebar follows + * 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 @@ -312,7 +312,7 @@ export interface SidebarWorkspacesService { /** * 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 * structurally, exactly like the other service faces above. */ @@ -324,7 +324,7 @@ export interface SidebarInvariantsService { ): () => 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 { /** * Register one namespace schema (the resolved value layers schema defaults, @@ -354,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 * returned disposer to the contributing fiber so unloading unregisters them. */ @@ -380,7 +380,7 @@ export interface SidebarAgent { declare module 'cordis' { interface Context { - httpServer: SidebarHttpServer + webServer: SidebarWebServer sessions: SidebarSessionStore & SidebarSessionsService connection: SidebarConnectionHandle loader: SidebarLoader @@ -396,13 +396,13 @@ declare module 'cordis' { */ locale: SidebarLocaleService /** - * The host background-task registry (`ctx.get('tasks')`; optional — the + * The host background-job registry (`ctx.get('jobs')`; optional — the * 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 - * resolve the caller the tasks fence compares against). + * resolve the caller the jobs fence compares against). */ agents: SidebarAgentsService /** @@ -415,7 +415,7 @@ declare module 'cordis' { * Subscribe to the session append feed (mirror of the cordis event API): * the listener receives every appended session event with the LIVE * 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 * the disposer. */ diff --git a/src/index.ts b/src/index.ts index 400b09e..5862109 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,7 +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 { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts' import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts' export { Config } @@ -59,7 +59,7 @@ export type { 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. */ -export const inject = ['httpServer', 'sessions', 'loader', 'tools'] +export const inject = ['webServer', 'sessions', 'loader', 'tools'] /** Content types for the media route, by extension. */ const MEDIA_TYPES: Record = { @@ -199,12 +199,12 @@ 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 + // Background jobs: the LIST rides the harness's `session/jobs` push // 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 - // task_output cursor is never consumed) and kill (the registry's stock - // API). A deployment without the tasks registry downgrades kill to a 503. - const tasksApi: SidebarTasksRoutes = buildTasksApi(ctx, resolved.readLimit) + // job_output cursor is never consumed) and kill (the registry's stock + // API). A deployment without the jobs registry downgrades kill to a 503. + const jobsApi: SidebarJobsRoutes = buildJobsApi(ctx, resolved.readLimit) return { 'session.cwd': (payload) => { const { sessionId, cwd } = cwdOf(payload) @@ -334,14 +334,14 @@ function buildApi( agentPtyRegistry.close(uuid) 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 - // task_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 - // through the harness's session/tasks push mirror, so no list route - // exists. Kill is fenced to the owning session by the tasks registry. - 'tasks.output': (payload) => tasksApi.output(payload), - 'tasks.kill': (payload) => tasksApi.kill(payload), + // job_output cursor is never touched, so the human pane can never steal + // the agent's bytes), and kill one job. The job LIST itself arrives + // through the harness's session/jobs push mirror, so no list route + // exists. Kill is fenced to the owning session by the jobs registry. + 'jobs.output': (payload) => jobsApi.output(payload), + 'jobs.kill': (payload) => jobsApi.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 @@ -425,7 +425,7 @@ function buildApi( /** * 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 * {@link Config} and fills defaults, direct callers get them from * {@link resolveSidebarConfig}. @@ -503,7 +503,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void { // ── JSON API ──────────────────────────────────────────────────────────── const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, () => settingsFace) - ctx.effect(() => ctx.httpServer.register({ + ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/sidebar/api', 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') // ── Media route (images for the editor) ───────────────────────────────── - ctx.effect(() => ctx.httpServer.register({ + ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/sidebar/file', 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 // the URL (e.g. a popup opened by a previewed page) stays in an opaque // origin with no same-origin access to the GUI. - ctx.effect(() => ctx.httpServer.register({ + ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/sidebar/html', 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 // contract the host has always had). const wss = new WebSocketServer({ noServer: true }) - ctx.effect(() => ctx.httpServer.registerUpgrade({ + ctx.effect(() => ctx.webServer.registerUpgrade({ path: '/sidebar/ws/terminal', handler: (req, socket, head) => { 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 // fires a change here, which converges the view). const agentListWss = new WebSocketServer({ noServer: true }) - ctx.effect(() => ctx.httpServer.registerUpgrade({ + ctx.effect(() => ctx.webServer.registerUpgrade({ path: '/sidebar/ws/agent-terminals', handler: (req, socket, head) => { if (!fence(req)) { diff --git a/src/invariant.ts b/src/invariant.ts index 0f2d3b1..8b6a5ae 100644 --- a/src/invariant.ts +++ b/src/invariant.ts @@ -15,7 +15,7 @@ export const inject = ['invariants'] /** * 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 * plain snapshot registry asserted by the behavior specs. The route fence, * pty quota, and store semantics are each observed through their seams. diff --git a/src/tasks-routes.ts b/src/jobs-routes.ts similarity index 70% rename from src/tasks-routes.ts rename to src/jobs-routes.ts index 3fcf0ba..81e4e86 100644 --- a/src/tasks-routes.ts +++ b/src/jobs-routes.ts @@ -1,31 +1,31 @@ /** - * Background-task routes of the /sidebar JSON API ('tasks.output' / - * 'tasks.kill'). The task LIST needs no route: it arrives through the - * harness's `session/tasks` push mirror (`tasksBySession` in the sessions + * Background-job routes of the /sidebar JSON API ('jobs.output' / + * 'jobs.kill'). The job LIST needs no route: it arrives through the + * harness's `session/jobs` push mirror (`jobsBySession` in the sessions * list feed). The routes: * - * - 'tasks.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 - * of `task_output` name the task via `arguments.task_id`, and the paired + * - 'jobs.output' — REPLAYS the output the MODEL has read so far for one + * job. The source is the owner session's own event log: `tool/call` rows + * of `job_output` name the job via `arguments.job_id`, and the paired * `tool/result` rows carry the finalized content the model received. * 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 - * 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). - * This touches NO DSH source: the model's `task_output` cursor is never - * consumed, and the pane stays empty until the agent reads the task. - * - 'tasks.kill' — the registry's stock `kill` (a pristine DSH API), + * This touches NO DSH source: the model's `job_output` cursor is never + * consumed, and the pane stays empty until the agent reads the job. + * - 'jobs.kill' — the registry's stock `kill` (a pristine DSH API), * fenced by the owning session via the live agent caller. Absent registry * → 503, mirroring the settings routes' optional-service downgrade. */ import type { Context, SidebarSessionEvent } from './context-types.ts' import { requireString, SidebarError } from './wire.ts' -/** The two background-task routes of the sidebar API. */ -export interface SidebarTasksRoutes { - /** The output the model has read so far for one task (event replay, capped). */ +/** The two background-job routes of the sidebar API. */ +export interface SidebarJobsRoutes { + /** The output the model has read so far for one job (event replay, capped). */ 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' } } @@ -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. */ function isNoNewOutput(text: string): boolean { return text.startsWith('(no new output)') } -/** One compact task_output trace (a tool/call or its paired tool/result). */ -interface TaskOutputTrace { +/** One compact job_output trace (a tool/call or its paired tool/result). */ +interface JobOutputTrace { seq: number kind: 'call' | 'result' /** The tool call identity pairing the two rows. */ callId: string - /** tool/call: the task id parsed from the model arguments. */ - taskId?: string + /** tool/call: the job id parsed from the model arguments. */ + jobId?: string /** tool/result: the finalized text the model received. */ text?: string /** tool/result: whether the result was an error (read counts, text skipped). */ isError?: boolean } -/** Extract the task_output trace of one raw session event (undefined = unrelated). */ -function traceOf(event: SidebarSessionEvent): TaskOutputTrace | undefined { +/** Extract the job_output trace of one raw session event (undefined = unrelated). */ +function traceOf(event: SidebarSessionEvent): JobOutputTrace | undefined { if (event.type === 'tool/call') { const data = event.data as { name?: unknown; callId?: unknown; arguments?: unknown } - if (data.name !== 'task_output' || typeof data.callId !== 'string') return undefined - let taskId: string | undefined + if (data.name !== 'job_output' || typeof data.callId !== 'string') return undefined + let jobId: string | undefined try { - const args = JSON.parse(typeof data.arguments === 'string' ? data.arguments : '') as { task_id?: unknown } - if (typeof args.task_id === 'string') taskId = args.task_id + const args = JSON.parse(typeof data.arguments === 'string' ? data.arguments : '') as { job_id?: unknown } + if (typeof args.job_id === 'string') jobId = args.job_id } catch { - // Malformed model arguments: not a task_output pair. + // Malformed model arguments: not a job_output pair. } - if (taskId === undefined) return undefined - return { seq: event.seq, kind: 'call', callId: data.callId, taskId } + if (jobId === undefined) return undefined + return { seq: event.seq, kind: 'call', callId: data.callId, jobId } } if (event.type === 'tool/result') { 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 /** - * The live task_output mirror: subscribes to the session append feed and - * caches the task_output traces the session store's own log can lag behind + * The live job_output mirror: subscribes to the session append feed and + * 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 * boundary, so `session.events` misses everything appended since — the very * reads the pane exists to show). Zero DSH writes: the api-proxy pushes the * same feed to browsers. */ -function createTaskOutputMirror(ctx: Context): { entries(sessionId: string): readonly TaskOutputTrace[] } { - const perSession = new Map() +function createJobOutputMirror(ctx: Context): { entries(sessionId: string): readonly JobOutputTrace[] } { + const perSession = new Map() // 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>() if (typeof ctx.on !== 'function') { // 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) } }) - 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) if (list === undefined) perSession.set(sessionId, list = []) 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 - * owner session's own event log with the live task_output mirror; `kill` - * reads the tasks/agents services lazily and degrades to a 503 when the + * Build the jobs routes bound to the plugin context. `output` merges the + * owner session's own event log with the live job_output mirror; `kill` + * reads the jobs/agents services lazily and degrades to a 503 when the * deployment lacks the registry. * @param ctx - host plugin context. * @param outputLimit - response cap for one output replay in bytes; longer * texts are sliced and flagged `truncated` (mirrors the fs.read cap). */ -export function buildTasksApi(ctx: Context, outputLimit: number): SidebarTasksRoutes { - const tasks = ctx.get('tasks') +export function buildJobsApi(ctx: Context, outputLimit: number): SidebarJobsRoutes { + const jobs = ctx.get('jobs') const agents = ctx.get('agents') - const mirror = createTaskOutputMirror(ctx) + const mirror = createJobOutputMirror(ctx) /** The live caller whose session id the registry fence compares against. */ 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 => - 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 { output(payload) { const sessionId = requireString(payload, 'sessionId') const id = requireString(payload, 'id') // Merge the store's event log (durable seed + whatever it received) // with the live mirror, deduped by seq — a trace never double-counts. - const bySeq = new Map() + const bySeq = new Map() for (const event of ctx.sessions.get(sessionId)?.events ?? []) { const trace = traceOf(event) if (trace !== undefined) 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. - const taskOf = new Map() + const jobOf = new Map() const parts: string[] = [] let read = false for (const trace of [...bySeq.values()].sort((left, right) => left.seq - right.seq)) { if (trace.kind === 'call') { - if (trace.taskId !== undefined) taskOf.set(trace.callId, trace.taskId) - } else if (taskOf.get(trace.callId) === id) { + if (trace.jobId !== undefined) jobOf.set(trace.callId, trace.jobId) + } else if (jobOf.get(trace.callId) === id) { read = true if (trace.isError !== true && trace.text !== undefined && !isNoNewOutput(trace.text)) { parts.push(trace.text) @@ -238,8 +238,8 @@ export function buildTasksApi(ctx: Context, outputLimit: number): SidebarTasksRo } }, kill(payload) { - if (tasks === undefined) { - throw new SidebarError('task-error', 'the background-task registry is not mounted in this deployment', 503) + if (jobs === undefined) { + throw new SidebarError('job-error', 'the background-job registry is not mounted in this deployment', 503) } const sessionId = requireString(payload, 'sessionId') const id = requireString(payload, 'id') @@ -248,7 +248,7 @@ export function buildTasksApi(ctx: Context, outputLimit: number): SidebarTasksRo ? record.reason : 'user requested via sidebar' try { - return { ok: true, outcome: tasks.kill(id, callerOf(sessionId), reason) } + return { ok: true, outcome: jobs.kill(id, callerOf(sessionId), reason) } } catch (error) { throw registryError(error) } diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index 5fcf9a5..680c30d 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -22,10 +22,10 @@ export interface SidebarPrefs { autoOpenSubagent: boolean /** * Whether the sidebar auto-activates (opens the panel) and expands the - * Tasks page when a NEW background task appears for the current - * conversation (any new task id, not just the first one). + * Jobs page when a NEW background job appears for the current + * conversation (any new job id, not just the first one). */ - autoOpenTasks: boolean + autoOpenJobs: boolean /** * Whether the model-facing agent terminal tools (terminal_create / list / * send / read / wait_for / resize / signal / close) are injected into the @@ -109,7 +109,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { openByDefault: true, defaultWidthPercent: WIDTH_PERCENT_DEFAULT, autoOpenSubagent: true, - autoOpenTasks: true, + autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, diff --git a/src/pty-manager.ts b/src/pty-manager.ts index 9e84e04..bd483e6 100644 --- a/src/pty-manager.ts +++ b/src/pty-manager.ts @@ -19,7 +19,7 @@ const TRANSCRIPT_LIMIT = 1 << 20 * 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 * 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. */ export function ensureSpawnHelper(): void { diff --git a/src/wire.ts b/src/wire.ts index 53ce473..ced0bea 100644 --- a/src/wire.ts +++ b/src/wire.ts @@ -15,7 +15,7 @@ export type SidebarErrorCode = | 'fs-error' | 'git-error' | 'pty-error' - | 'task-error' + | 'job-error' | 'settings-rejected' | 'settings-conflict' | 'internal' diff --git a/tests/builtins.spec.ts b/tests/builtins.spec.ts index 31ecd2a..34dd6f3 100644 --- a/tests/builtins.spec.ts +++ b/tests/builtins.spec.ts @@ -44,7 +44,7 @@ describe('built-in tab registrations', () => { it('the subagent tab declares its auto-open related settings', () => { const { service } = setup() 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', () => { diff --git a/tests/tasks-routes.spec.ts b/tests/jobs-routes.spec.ts similarity index 53% rename from tests/tasks-routes.spec.ts rename to tests/jobs-routes.spec.ts index e1f67ee..e8d9338 100644 --- a/tests/tasks-routes.spec.ts +++ b/tests/jobs-routes.spec.ts @@ -1,21 +1,21 @@ /** - * Host route tests for the background-task API ('tasks.output' / 'tasks.kill'). - * 'tasks.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) — - * the model's cursor is never touched, so nothing is stolen and unread tasks - * report `read: false`. 'tasks.kill' uses the registry's stock kill, fenced + * Host route tests for the background-job API ('jobs.output' / 'jobs.kill'). + * 'jobs.output' REPLAYS the output the model has read so far from the owner + * 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 jobs + * report `read: false`. 'jobs.kill' uses the registry's stock kill, fenced * by the owning session, with a 503 when the registry is absent. */ 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 type { Context, SidebarSessionEvent } from '../src/context-types.ts' -/** A context whose `get` serves only the tasks/agents faces, with a session store. */ -function ctxWith(sessions: unknown, tasks: unknown, agents: unknown): Context { +/** A context whose `get` serves only the jobs/agents faces, with a session store. */ +function ctxWith(sessions: unknown, jobs: unknown, agents: unknown): Context { return { 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 } @@ -44,13 +44,13 @@ function ctxWithFeed(sessions: unknown): { /** A stub live agent (the fence compares `id` only). */ const agent = (id: string) => ({ id, session: { header: { cwd: '/p' } } }) -/** One task_output tool/call event. */ -function taskOutputCall(seq: number, callId: string, taskId: string): SidebarSessionEvent { - return { type: 'tool/call', seq, time: seq, data: { name: 'task_output', callId, arguments: JSON.stringify({ task_id: taskId }) } } +/** One job_output tool/call event. */ +function jobOutputCall(seq: number, callId: string, jobId: string): SidebarSessionEvent { + 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. */ -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 { type: 'tool/result', seq, @@ -74,18 +74,18 @@ function session(events: SidebarSessionEvent[]): unknown { return { header: { cwd: '/p' }, events } } -describe('tasks.output route (event replay)', () => { - it('concatenates the task_output results the model read for the task, oldest first', () => { +describe('jobs.output route (event replay)', () => { + it('concatenates the job_output results the model read for the job, oldest first', () => { const events = [ - taskOutputCall(0, 'c1', 'bash-1'), - taskOutputResult(1, 'c1', 'line1\nline2\n[status: running]'), - taskOutputCall(2, 'c2', 'bash-1'), - taskOutputResult(3, 'c2', 'line3\n[status: completed, exit code: 0]'), - // Another task's reads are ignored. - taskOutputCall(4, 'c3', 'bash-2'), - taskOutputResult(5, 'c3', 'other task output'), + jobOutputCall(0, 'c1', 'bash-1'), + jobOutputResult(1, 'c1', 'line1\nline2\n[status: running]'), + jobOutputCall(2, 'c2', 'bash-1'), + jobOutputResult(3, 'c2', 'line3\n[status: completed, exit code: 0]'), + // Another job's reads are ignored. + jobOutputCall(4, 'c3', 'bash-2'), + 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({ text: 'line1\nline2\n[status: running]\nline3\n[status: completed, exit code: 0]', truncated: false, @@ -95,14 +95,14 @@ describe('tasks.output route (event replay)', () => { it('skips the controller\'s "(no new output)" deltas and error results', () => { const events = [ - taskOutputCall(0, 'c1', 'bash-1'), - taskOutputResult(1, 'c1', 'real output\n[status: running]'), - taskOutputCall(2, 'c2', 'bash-1'), - taskOutputResult(3, 'c2', '(no new output)\n[status: running]'), - taskOutputCall(4, 'c3', 'bash-1'), - taskOutputResult(5, 'c3', 'boom', { isError: true }), + jobOutputCall(0, 'c1', 'bash-1'), + jobOutputResult(1, 'c1', 'real output\n[status: running]'), + jobOutputCall(2, 'c2', 'bash-1'), + jobOutputResult(3, 'c2', '(no new output)\n[status: running]'), + jobOutputCall(4, 'c3', 'bash-1'), + 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({ text: 'real output\n[status: running]', 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)', () => { - const events = [taskOutputCall(0, 'c1', 'bash-2')] - const tasks = { kill: vi.fn() } - const api = buildTasksApi(ctxWith({ get: () => session(events) }, tasks, undefined), 100) + it('reports read:false until the model reads the job (no registry call at all)', () => { + const events = [jobOutputCall(0, 'c1', 'bash-2')] + const jobs = { kill: vi.fn() } + const api = buildJobsApi(ctxWith({ get: () => session(events) }, jobs, undefined), 100) 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. - 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 read exists only on the live session/event feed. 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 }) - // The live feed delivers the task_output call and its result. - emit({ id: 's1' }, taskOutputCall(100, 'c-live', 'bash-1')) - emit({ id: 's1' }, taskOutputResult(101, 'c-live', 'live line\n[status: completed, exit code: 0]')) + // The live feed delivers the job_output call and its result. + emit({ id: 's1' }, jobOutputCall(100, 'c-live', 'bash-1')) + emit({ id: 's1' }, jobOutputResult(101, 'c-live', 'live line\n[status: completed, exit code: 0]')) expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: 'live line\n[status: completed, exit code: 0]', truncated: false, 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. - emit({ id: 's1' }, taskOutputResult(102, 'c-other', 'unpaired line')) - emit({ id: 's2' }, taskOutputCall(103, 'c-s2', 'bash-1')) - emit({ id: 's2' }, taskOutputResult(104, 'c-s2', 'other session line')) + emit({ id: 's1' }, jobOutputResult(102, 'c-other', 'unpaired line')) + emit({ id: 's2' }, jobOutputCall(103, 'c-s2', 'bash-1')) + emit({ id: 's2' }, jobOutputResult(104, 'c-s2', 'other session line')) expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: 'live line\n[status: completed, exit code: 0]', 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 // ALSO reached the store log would double-count — the seq dedupe keeps // 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 api = buildTasksApi(ctx, 512 * 1024) - emit({ id: 's1' }, taskOutputCall(106, 'c-live', 'bash-1')) - emit({ id: 's1' }, taskOutputResult(107, 'c-live', 'live line')) + const api = buildJobsApi(ctx, 512 * 1024) + emit({ id: 's1' }, jobOutputCall(106, 'c-live', 'bash-1')) + emit({ id: 's1' }, jobOutputResult(107, 'c-live', 'live line')) expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: 'seed line\nlive line', truncated: false, @@ -169,8 +169,8 @@ describe('tasks.output route (event replay)', () => { }) it('caps oversized replays with the truncated flag', () => { - const events = [taskOutputCall(0, 'c1', 'bash-1'), taskOutputResult(1, 'c1', 'x'.repeat(10_000))] - const api = buildTasksApi(ctxWith({ get: () => session(events) }, undefined, undefined), 100) + const events = [jobOutputCall(0, 'c1', 'bash-1'), jobOutputResult(1, 'c1', 'x'.repeat(10_000))] + const api = buildJobsApi(ctxWith({ get: () => session(events) }, undefined, undefined), 100) const value = api.output({ sessionId: 's1', id: 'bash-1' }) expect(value.text).toBe('x'.repeat(100)) 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', () => { - 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>({ code: 'bad-request' })) expect(() => api.output({ sessionId: 's1' })).toThrowError(expect.objectContaining>({ code: 'bad-request' })) }) }) -describe('tasks.kill route', () => { +describe('jobs.kill route', () => { 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 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' })) .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', () => { - const tasks = { kill: vi.fn(() => 'already-finished' as const) } - const api = buildTasksApi(ctxWith({ get: () => undefined }, tasks, undefined), 100) + const jobs = { kill: vi.fn(() => 'already-finished' as const) } + const api = buildJobsApi(ctxWith({ get: () => undefined }, jobs, 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') + expect(jobs.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({ get: () => undefined }, tasks, undefined), 100) + it('maps registry refusals to a 404 job-error', () => { + const jobs = { kill: vi.fn(() => { throw new Error('unknown job bash-9') }) } + const api = buildJobsApi(ctxWith({ get: () => undefined }, jobs, undefined), 100) expect(() => api.kill({ sessionId: 's1', id: 'bash-9' })).toThrowError( - expect.objectContaining>({ code: 'task-error', status: 404 }), + expect.objectContaining>({ code: 'job-error', status: 404 }), ) }) - it('degrades to a 503 when the tasks registry is absent (output keeps working)', () => { - const api = buildTasksApi(ctxWith({ get: () => session([]) }, undefined, undefined), 100) + it('degrades to a 503 when the jobs registry is absent (output keeps working)', () => { + const api = buildJobsApi(ctxWith({ get: () => session([]) }, undefined, undefined), 100) expect(() => api.kill({ sessionId: 's1', id: 'bash-1' })).toThrowError( - expect.objectContaining>({ code: 'task-error', status: 503 }), + expect.objectContaining>({ code: 'job-error', status: 503 }), ) expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false }) }) diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index f67acb2..195545a 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -17,7 +17,7 @@ describe('dsh-better-sidebar plugin export shape', () => { const unwrapped = loader.unwrapExports(sidebar) as Record expect(unwrapped).toBe(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(typeof unwrapped.apply).toBe('function') }) @@ -45,8 +45,8 @@ describe('dsh-better-sidebar plugin export shape', () => { expect(resolved.openByDefault).toBe(true) expect(resolved.defaultWidthPercent).toBe(30) expect(resolved.autoOpenSubagent).toBe(true) - // A new background task auto-opens the Tasks page too. - expect(resolved.autoOpenTasks).toBe(true) + // A new background job auto-opens the Jobs page too. + expect(resolved.autoOpenJobs).toBe(true) // The terminal tools default OFF (the feature is dormant until the user // enables it in the side card settings). expect(resolved.agentTerminalTools).toBe(false) @@ -59,6 +59,6 @@ describe('dsh-better-sidebar plugin export shape', () => { const overridden = (PrefsSchema as unknown as { (input: Record | undefined): Record })({ 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: {} }) }) }) diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 89aa3d6..601eed5 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -17,7 +17,7 @@ import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-typ interface FakeContext { loader: { entries: () => never[] } - httpServer: { + webServer: { register: (route: SidebarWebRoute) => () => void registerUpgrade: (route: SidebarWebUpgradeRoute) => () => void } @@ -27,7 +27,7 @@ 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. */ + /** Optional services (jobs/agents) are read lazily; absent → undefined. */ get: (key: string) => undefined } @@ -43,7 +43,7 @@ describe('host plugin smoke', () => { const effects: Array<() => void | (() => void)> = [] const ctx: FakeContext = { loader: { entries: () => [] }, - httpServer: { + webServer: { register: (route) => { routes.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 // never runs (cordis' service-less inject behaves the same). 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, } apply(ctx as never) @@ -309,7 +309,7 @@ describe('session cwd resolution over the API route', () => { const routes: SidebarWebRoute[] = [] const ctx = { loader: { entries: () => [] }, - httpServer: { + webServer: { register: (route: SidebarWebRoute) => { routes.push(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() }, // No settings service: the namespace registration never runs. 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, } apply(ctx as never) @@ -459,7 +459,7 @@ describe('side card settings routes', () => { const routes: SidebarWebRoute[] = [] const ctx = { loader: { entries: () => [] }, - httpServer: { + webServer: { register: (route: SidebarWebRoute) => { routes.push(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 }) 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, } apply(ctx as never) @@ -514,7 +514,7 @@ describe('side card settings routes', () => { openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, - autoOpenTasks: true, + autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, @@ -645,7 +645,7 @@ describe('agent terminal tool gating', () => { } const ctx = { loader: { entries: () => [] }, - httpServer: { + webServer: { register: (route: SidebarWebRoute) => { void route; return () => {} }, registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} }, }, @@ -656,7 +656,7 @@ describe('agent terminal tool gating', () => { if (deps.includes('settings')) callback({ settings }) 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, } apply(ctx as never) diff --git a/tests/subagent-tasks-view.spec.tsx b/tests/subagent-jobs-view.spec.tsx similarity index 86% rename from tests/subagent-tasks-view.spec.tsx rename to tests/subagent-jobs-view.spec.tsx index b9cb894..e1bb464 100644 --- a/tests/subagent-tasks-view.spec.tsx +++ b/tests/subagent-jobs-view.spec.tsx @@ -1,7 +1,7 @@ /** - * 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 + * Subagent page tests for the background-job section: rows render from the + * `jobsBySession` mirror, clicking a row peeks its output through + * `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 * polls while the page is hidden. */ @@ -80,7 +80,7 @@ function baseSnapshot(): SidebarSessionList { child: { id: 'child', displayTitle: '子代理', origin: 'subagent', parentId: 'root', running: true }, }, subagentsByParent: {}, - tasksBySession: { + jobsBySession: { root: [ { 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) => { const method = String(url).split('/').pop() 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 }) - // 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({ ok: true, value: { @@ -109,7 +109,7 @@ beforeEach(() => { }, }) } - if (method === 'tasks.kill') { + if (method === 'jobs.kill') { killCalls.push({ sessionId: body.sessionId, id: body.id }) return jsonResponse({ ok: true, value: { ok: true, outcome: 'requested' } }) } @@ -123,15 +123,15 @@ afterEach(() => { 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', () => { +describe('SubagentView background jobs', () => { + it('renders the tree jobs 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 运行中') + 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') @@ -142,12 +142,12 @@ describe('SubagentView background tasks', () => { unmount() }) - it('renders nothing task-related when the mirror is empty', () => { - const store = makeStore({ current: 'root', byId: { root: { id: 'root', displayTitle: '主会话' } }, subagentsByParent: {}, tasksBySession: {} }) + it('renders nothing job-related when the mirror is empty', () => { + const store = makeStore({ current: 'root', byId: { root: { id: 'root', displayTitle: '主会话' } }, subagentsByParent: {}, jobsBySession: {} }) const { container, unmount } = mount( createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), ) - expect(container.textContent).not.toContain('后台命令') + expect(container.textContent).not.toContain('后台任务') unmount() }) @@ -157,19 +157,19 @@ describe('SubagentView background tasks', () => { 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 + // The mirror empties (all jobs 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(), jobsBySession: {} }) }) + expect(container.textContent).not.toContain('后台任务') + // And returning jobs 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 () => { + it('shows the selected job output in the bottom dock, closeable', async () => { const store = makeStore(baseSnapshot()) const { container, unmount } = mount( 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') 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 replay). + // One dock, now fed by the second job (its owner session scopes the replay). expect(outputCalls).toEqual([ { sessionId: 'root', id: 'bash-1' }, { sessionId: 'child', id: 'bash-2' }, @@ -211,11 +211,11 @@ describe('SubagentView background tasks', () => { 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() - snapshot.tasksBySession = { + snapshot.jobsBySession = { root: [ - ...(snapshot.tasksBySession?.root ?? []), + ...(snapshot.jobsBySession?.root ?? []), { 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 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). expect(container.textContent).toContain('等待模型读取该任务的输出') 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) => ({ id: `bash-${index + 10}`, kind: 'bash' as const, @@ -244,12 +244,12 @@ describe('SubagentView background tasks', () => { current: 'root', byId: { root: { id: 'root', displayTitle: '主会话' } }, subagentsByParent: {}, - tasksBySession: { root: many }, + jobsBySession: { root: many }, }) const { container, unmount } = mount( 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) // 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 @@ -259,7 +259,7 @@ describe('SubagentView background tasks', () => { 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 { container, unmount } = mount( createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), diff --git a/tests/subagent-jobs.spec.ts b/tests/subagent-jobs.spec.ts new file mode 100644 index 0000000..c2d593f --- /dev/null +++ b/tests/subagent-jobs.spec.ts @@ -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 = { + jobDurationSeconds: '{seconds}秒', + jobDurationMinutes: '{minutes}分{seconds}秒', + jobDurationHours: '{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 job(id: string, over: Partial = {}): 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): 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) + }) +}) diff --git a/tests/subagent-tasks.spec.ts b/tests/subagent-tasks.spec.ts deleted file mode 100644 index fbe1370..0000000 --- a/tests/subagent-tasks.spec.ts +++ /dev/null @@ -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 = { - 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秒') - }) -}) - -describe('detectNewTask', () => { - const list = (tasksBySession: Record): 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) - }) -}) diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index 23a6311..fac1fd5 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -1150,7 +1150,7 @@ describe('side card preferences', () => { openByDefault: false, defaultWidthPercent: 60, autoOpenSubagent: false, - autoOpenTasks: true, + autoOpenJobs: true, agentTerminalTools: true, bottomPanelAutoTerminal: true, interceptOpenPath: true, @@ -1169,7 +1169,7 @@ describe('side card preferences', () => { openByDefault: true, defaultWidthPercent: 33, autoOpenSubagent: true, - autoOpenTasks: true, + autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, @@ -1188,7 +1188,7 @@ describe('side card preferences', () => { openByDefault: false, defaultWidthPercent: 40, autoOpenSubagent: true, - autoOpenTasks: true, + autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, @@ -1208,10 +1208,10 @@ describe('side card preferences', () => { .toBe(false) expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools) .toBe(true) - // The task auto-open is ON by default; only an explicit false turns it off. - expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenTasks: 1 }))).autoOpenTasks) + // The job auto-open is ON by default; only an explicit false turns it off. + expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenJobs: 1 }))).autoOpenJobs) .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) }) @@ -1242,9 +1242,9 @@ describe('side card preferences', () => { const store = createSidebarStore() // Node environment: no window → the width falls back to PANEL_DEFAULT, // 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') - 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() expect(snapshot.sessionId).toBe('fresh-session') 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', () => { 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') const state = store.getSnapshot().state! const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs) @@ -1288,7 +1288,7 @@ describe('side card preferences', () => { expect(state.splits.kind).toBe('leaf') // Re-enabling seeds the explorer tab again. 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') const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) expect(openTabs.map(tab => tab.type)).toEqual(['explorer'])