feat: 适配 DSH 命名契约重命名(jobs/terminal/webServer)并处理 Session log 按钮位置
- tasks→jobs:ctx.jobs / job_output / job_id / session/jobs / jobsBySession / Job* 词汇 - httpServer→webServer(inject + augmentation + register/registerUpgrade) - autoOpenTasks→autoOpenJobs 偏好键 - 右上角开关簇回到角落,注入 CSS 挤开 header 的「Session log」胶囊
This commit is contained in:
@@ -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>/`),插件永远作为独立包被 profile 引用,不反向侵入 DSH。
|
||||
- 需要 harness 没有的能力时,用 DSH **现成的只读/公开 API** 或插件自有路由实现(参考 §7 的 `tasks.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 DSH。
|
||||
- 需要 harness 没有的能力时,用 DSH **现成的只读/公开 API** 或插件自有路由实现(参考 §7 的 `jobs.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 DSH。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 保持流畅
|
||||
|
||||
+1
-1
@@ -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),
|
||||
|
||||
+24
-12
@@ -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<SidebarSessionList | undefined>(undefined)
|
||||
const jobBaselineRef = useRef<SidebarSessionList | undefined>(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 }))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+103
-103
@@ -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<AbortController | undefined>(undefined)
|
||||
const preRef = useRef<HTMLPreElement>(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 (
|
||||
<div className={css.tasksPane} role="region" aria-label={`${task.label} ${t('tasks')}`}>
|
||||
<div className={css.tasksPaneHeader}>
|
||||
<StateDot state={taskDotState(task.status)} className={css.tasksPaneDot} />
|
||||
<span className={css.tasksPaneLabel} title={task.label}>{task.label}</span>
|
||||
<span className={css.tasksPaneStatus}>
|
||||
{taskStatusLabel(task.status, t)}
|
||||
{task.detail !== undefined && task.detail !== '' ? ` · ${task.detail}` : ''}
|
||||
<div className={css.jobsPane} role="region" aria-label={`${job.label} ${t('jobs')}`}>
|
||||
<div className={css.jobsPaneHeader}>
|
||||
<StateDot state={jobDotState(job.status)} className={css.jobsPaneDot} />
|
||||
<span className={css.jobsPaneLabel} title={job.label}>{job.label}</span>
|
||||
<span className={css.jobsPaneStatus}>
|
||||
{jobStatusLabel(job.status, t)}
|
||||
{job.detail !== undefined && job.detail !== '' ? ` · ${job.detail}` : ''}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.tasksPaneClose}
|
||||
className={css.jobsPaneClose}
|
||||
aria-label={t('close')}
|
||||
title={t('close')}
|
||||
onClick={onClose}
|
||||
@@ -429,18 +429,18 @@ function TaskOutputPane(props: {
|
||||
<IconStopOutline16 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
{state === 'loading' && <div className={css.tasksPaneHint}>{t('loading')}</div>}
|
||||
{state === 'loading' && <div className={css.jobsPaneHint}>{t('loading')}</div>}
|
||||
{state === 'error' && (
|
||||
<div className={`${css.tasksPaneHint} ${css.tasksPaneError}`}>{t('taskOutputError')}</div>
|
||||
<div className={`${css.jobsPaneHint} ${css.jobsPaneError}`}>{t('jobOutputError')}</div>
|
||||
)}
|
||||
{typeof state === 'object' && (
|
||||
<>
|
||||
{state.text.length > 0
|
||||
? <pre ref={preRef} className={css.tasksPanePre}>{state.text}</pre>
|
||||
? <pre ref={preRef} className={css.jobsPanePre}>{state.text}</pre>
|
||||
: state.read
|
||||
? <div className={css.tasksPaneHint}>{t('taskNoOutput')}</div>
|
||||
: <div className={css.tasksPaneHint}>{t('taskNotReadYet')}</div>}
|
||||
{state.truncated && <div className={css.tasksPaneHint}>{t('taskOutputTruncated')}</div>}
|
||||
? <div className={css.jobsPaneHint}>{t('jobNoOutput')}</div>
|
||||
: <div className={css.jobsPaneHint}>{t('jobNotReadYet')}</div>}
|
||||
{state.truncated && <div className={css.jobsPaneHint}>{t('jobOutputTruncated')}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -448,24 +448,24 @@ function TaskOutputPane(props: {
|
||||
}
|
||||
|
||||
/**
|
||||
* The background-task section of the Subagent page: every task of the whole
|
||||
* The background-job section of the Subagent page: every job of the whole
|
||||
* current tree (main agent + subagents, owner-labeled), fed by the harness
|
||||
* `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<string | undefined>(undefined)
|
||||
const [armedId, setArmedId] = useState<string | undefined>(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<void> => {
|
||||
setKillingId(row.task.id)
|
||||
const kill = useCallback(async (row: TreeJob): Promise<void> => {
|
||||
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 (
|
||||
<>
|
||||
<section className={css.tasks} aria-label={t('tasks')}>
|
||||
<div className={css.tasksHeader}>
|
||||
<span className={css.tasksTitle}>{t('tasks')}</span>
|
||||
<span className={css.tasksCount}>{countLabel}</span>
|
||||
<section className={css.jobs} aria-label={t('jobs')}>
|
||||
<div className={css.jobsHeader}>
|
||||
<span className={css.jobsTitle}>{t('jobs')}</span>
|
||||
<span className={css.jobsCount}>{countLabel}</span>
|
||||
</div>
|
||||
<ul className={css.tasksList} aria-label={t('tasks')}>
|
||||
<ul className={css.jobsList} aria-label={t('jobs')}>
|
||||
{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 (
|
||||
<li
|
||||
key={task.id}
|
||||
key={job.id}
|
||||
className={clsx(
|
||||
css.tasksRow,
|
||||
!live && css.tasksRowSettled,
|
||||
selected && css.tasksRowSelected,
|
||||
css.jobsRow,
|
||||
!live && css.jobsRowSettled,
|
||||
selected && css.jobsRowSelected,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={css.tasksRowMain}
|
||||
className={css.jobsRowMain}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${task.label} ${secondary}`}
|
||||
onClick={() => { setSelectedId(selected ? undefined : task.id) }}
|
||||
aria-label={`${job.label} ${secondary}`}
|
||||
onClick={() => { setSelectedId(selected ? undefined : job.id) }}
|
||||
>
|
||||
<StateDot state={taskDotState(task.status)} className={css.tasksDot} />
|
||||
<span className={css.tasksContent}>
|
||||
<span className={css.tasksLabelLine}>
|
||||
<span className={css.tasksKind}>{task.kind}</span>
|
||||
<span className={css.tasksLabel} title={task.label}>{task.label}</span>
|
||||
<StateDot state={jobDotState(job.status)} className={css.jobsDot} />
|
||||
<span className={css.jobsContent}>
|
||||
<span className={css.jobsLabelLine}>
|
||||
<span className={css.jobsKind}>{job.kind}</span>
|
||||
<span className={css.jobsLabel} title={job.label}>{job.label}</span>
|
||||
</span>
|
||||
<span className={css.tasksSecondary}>{secondary}</span>
|
||||
<span className={css.jobsSecondary}>{secondary}</span>
|
||||
</span>
|
||||
</button>
|
||||
{task.status === 'running' && (
|
||||
{job.status === 'running' && (
|
||||
<button
|
||||
type="button"
|
||||
className={armed ? `${css.tasksKill} ${css.tasksKillArmed}` : css.tasksKill}
|
||||
aria-label={armed ? t('taskKillConfirm') : t('taskKill')}
|
||||
title={armed ? t('taskKillConfirm') : t('taskKill')}
|
||||
className={armed ? `${css.jobsKill} ${css.jobsKillArmed}` : css.jobsKill}
|
||||
aria-label={armed ? t('jobKillConfirm') : t('jobKill')}
|
||||
title={armed ? t('jobKillConfirm') : t('jobKill')}
|
||||
disabled={killing}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (armed) void kill(row)
|
||||
else setArmedId(task.id)
|
||||
else setArmedId(job.id)
|
||||
}}
|
||||
>
|
||||
{armed ? t('taskKillConfirm') : <IconStopOutline16 size={12} />}
|
||||
{armed ? t('jobKillConfirm') : <IconStopOutline16 size={12} />}
|
||||
</button>
|
||||
)}
|
||||
{killFailed && <span className={css.tasksKillError}>{t('taskKillError')}</span>}
|
||||
{killFailed && <span className={css.jobsKillError}>{t('jobKillError')}</span>}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
{selectedRow !== undefined && (
|
||||
<TaskOutputPane
|
||||
<JobOutputPane
|
||||
ownerSessionId={selectedRow.ownerSessionId}
|
||||
task={selectedRow.task}
|
||||
job={selectedRow.job}
|
||||
active={active}
|
||||
onClose={() => { setSelectedId(undefined) }}
|
||||
/>
|
||||
@@ -854,9 +854,9 @@ export function SubagentView(props: {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TasksSection
|
||||
<JobsSection
|
||||
byId={byId}
|
||||
tasksBySession={list.tasksBySession}
|
||||
jobsBySession={list.jobsBySession}
|
||||
rootId={rootId}
|
||||
active={active}
|
||||
/>
|
||||
|
||||
+14
-14
@@ -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<TaskOutputResult>('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<JobOutputResult>('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 } : {}),
|
||||
})),
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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;
|
||||
|
||||
+44
-44
@@ -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<keyof typeof zh, string> = {
|
||||
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<keyof typeof zh, string> = {
|
||||
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',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
@@ -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<Record<string, readonly SidebarTaskView[]>> | undefined,
|
||||
jobsBySession: Readonly<Record<string, readonly SidebarJobView[]>> | 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 | number>) => 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 | number>) => 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 })
|
||||
}
|
||||
+1
-1
@@ -79,7 +79,7 @@ export const PrefsSchema: z<SidebarPrefs> = 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),
|
||||
|
||||
+29
-29
@@ -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<void>
|
||||
}
|
||||
|
||||
/** 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 `<kind>-N` identity, stable for the task's whole life. */
|
||||
export interface SidebarJobView {
|
||||
/** Registry-issued `<kind>-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<Record<string, SidebarSubagentCatalog>>
|
||||
/**
|
||||
* Background tasks per session, last-wins from the harness's `session/tasks`
|
||||
* Background jobs per session, last-wins from the harness's `session/jobs`
|
||||
* push (a missing key is an empty set). Absent on runtime snapshots older
|
||||
* than the tasks mirror — the sidebar simply shows no task rows.
|
||||
* than the jobs mirror — the sidebar simply shows no job rows.
|
||||
*/
|
||||
tasksBySession?: Readonly<Record<string, readonly SidebarTaskView[]>>
|
||||
jobsBySession?: Readonly<Record<string, readonly SidebarJobView[]>>
|
||||
}
|
||||
|
||||
/** The client sessions service face (only the list feed is needed). */
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
+19
-19
@@ -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<string, string> = {
|
||||
@@ -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)) {
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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<string, TaskOutputTrace[]>()
|
||||
function createJobOutputMirror(ctx: Context): { entries(sessionId: string): readonly JobOutputTrace[] } {
|
||||
const perSession = new Map<string, JobOutputTrace[]>()
|
||||
// tool/call identities per session, so unrelated tool/result rows are
|
||||
// never cached (only task_output results pair with a cached call).
|
||||
// never cached (only job_output results pair with a cached call).
|
||||
const callIds = new Map<string, Set<string>>()
|
||||
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<number, TaskOutputTrace>()
|
||||
const bySeq = new Map<number, JobOutputTrace>()
|
||||
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<string, string>()
|
||||
const jobOf = new Map<string, string>()
|
||||
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)
|
||||
}
|
||||
+4
-4
@@ -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,
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ export type SidebarErrorCode =
|
||||
| 'fs-error'
|
||||
| 'git-error'
|
||||
| 'pty-error'
|
||||
| 'task-error'
|
||||
| 'job-error'
|
||||
| 'settings-rejected'
|
||||
| 'settings-conflict'
|
||||
| 'internal'
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<Partial<SidebarError>>({ code: 'bad-request' }))
|
||||
expect(() => api.output({ sessionId: 's1' })).toThrowError(expect.objectContaining<Partial<SidebarError>>({ code: 'bad-request' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('tasks.kill route', () => {
|
||||
describe('jobs.kill route', () => {
|
||||
it('kills with the forwarded reason and the live caller', () => {
|
||||
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<Partial<SidebarError>>({ code: 'task-error', status: 404 }),
|
||||
expect.objectContaining<Partial<SidebarError>>({ code: 'job-error', status: 404 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('degrades to a 503 when the tasks registry is absent (output keeps working)', () => {
|
||||
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<Partial<SidebarError>>({ code: 'task-error', status: 503 }),
|
||||
expect.objectContaining<Partial<SidebarError>>({ code: 'job-error', status: 503 }),
|
||||
)
|
||||
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false })
|
||||
})
|
||||
@@ -17,7 +17,7 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
const unwrapped = loader.unwrapExports(sidebar) as Record<string, unknown>
|
||||
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<string, unknown> | undefined): Record<string, unknown>
|
||||
})({ 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: {} })
|
||||
})
|
||||
})
|
||||
|
||||
+11
-11
@@ -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)
|
||||
|
||||
@@ -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) }),
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Pure-helper tests for the Subagent page's background-job section:
|
||||
* tree-membership collection, ordering, and status presentation mapping.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
collectTreeJobs,
|
||||
detectNewJob,
|
||||
formatJobDuration,
|
||||
isJobLive,
|
||||
orderJobs,
|
||||
jobDotState,
|
||||
jobStatusLabel,
|
||||
treeSessionIds,
|
||||
} from '../src/client/subagent-jobs.ts'
|
||||
import type { SidebarSessionList, SidebarSessionSummary, SidebarJobStatus, SidebarJobView } from '../src/context-types.ts'
|
||||
|
||||
/** The translator stub: renders duration templates like the real locale copy. */
|
||||
const templates: Record<string, string> = {
|
||||
jobDurationSeconds: '{seconds}秒',
|
||||
jobDurationMinutes: '{minutes}分{seconds}秒',
|
||||
jobDurationHours: '{hours}小时{minutes}分',
|
||||
}
|
||||
const t = (key: string, params?: Record<string, string | number>): string => {
|
||||
let text = templates[key] ?? key
|
||||
if (params !== undefined) {
|
||||
for (const [name, value] of Object.entries(params)) text = text.replaceAll(`{${name}}`, String(value))
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function summary(id: string, over: Partial<SidebarSessionSummary> = {}): SidebarSessionSummary {
|
||||
return { id, displayTitle: `title-${id}`, ...over }
|
||||
}
|
||||
|
||||
function job(id: string, over: Partial<SidebarJobView> = {}): SidebarJobView {
|
||||
return { id, kind: 'bash', label: `cmd ${id}`, status: 'running', startedAt: 1_000, ...over }
|
||||
}
|
||||
|
||||
describe('treeSessionIds', () => {
|
||||
it('includes the root and every subagent-origin session whose chain reaches it', () => {
|
||||
const byId = {
|
||||
root: summary('root'),
|
||||
child: summary('child', { origin: 'subagent', parentId: 'root' }),
|
||||
grand: summary('grand', { origin: 'subagent', parentId: 'child' }),
|
||||
orphan: summary('orphan', { origin: 'subagent', parentId: 'gone' }),
|
||||
other: summary('other', { origin: 'subagent', parentId: 'other-root' }),
|
||||
}
|
||||
const ids = treeSessionIds(byId, 'root')
|
||||
expect([...ids].sort()).toEqual(['child', 'grand', 'root'])
|
||||
})
|
||||
|
||||
it('fails soft on parent cycles and yields nothing without a root', () => {
|
||||
const byId = {
|
||||
root: summary('root'),
|
||||
a: summary('a', { origin: 'subagent', parentId: 'b' }),
|
||||
b: summary('b', { origin: 'subagent', parentId: 'a' }),
|
||||
}
|
||||
expect(treeSessionIds(byId, 'root').size).toBe(1)
|
||||
expect(treeSessionIds(byId, undefined).size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectTreeJobs', () => {
|
||||
it('collects jobs of the whole tree with owner titles, ignoring outside sessions', () => {
|
||||
const byId = {
|
||||
root: summary('root'),
|
||||
child: summary('child', { origin: 'subagent', parentId: 'root' }),
|
||||
}
|
||||
const jobsBySession = {
|
||||
root: [job('bash-1')],
|
||||
child: [job('bash-2', { status: 'completed', finishedAt: 2_000 })],
|
||||
stranger: [job('bash-9')],
|
||||
}
|
||||
const rows = collectTreeJobs(byId, jobsBySession, 'root')
|
||||
expect(rows.map(row => [row.ownerSessionId, row.ownerTitle, row.job.id]))
|
||||
.toEqual([['root', 'title-root', 'bash-1'], ['child', 'title-child', 'bash-2']])
|
||||
})
|
||||
|
||||
it('returns an empty list for an absent mirror or empty sets', () => {
|
||||
const byId = { root: summary('root') }
|
||||
expect(collectTreeJobs(byId, undefined, 'root')).toEqual([])
|
||||
expect(collectTreeJobs(byId, {}, 'root')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('orderJobs', () => {
|
||||
it('puts live rows first in start order, then settled rows newest-first', () => {
|
||||
const row = (id: string, status: SidebarJobStatus, startedAt: number, finishedAt?: number) => ({
|
||||
ownerSessionId: 'root',
|
||||
ownerTitle: 'root',
|
||||
job: job(id, { status, startedAt, ...(finishedAt !== undefined ? { finishedAt } : {}) }),
|
||||
})
|
||||
const rows = [
|
||||
row('old-settled', 'completed', 1_000, 2_000),
|
||||
row('live-2', 'running', 4_000),
|
||||
row('new-settled', 'killed', 1_500, 1_800),
|
||||
row('live-1', 'stopping', 3_000),
|
||||
]
|
||||
expect(orderJobs(rows).map(r => r.job.id)).toEqual(['live-1', 'live-2', 'old-settled', 'new-settled'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('status presentation helpers', () => {
|
||||
it('treats running and stopping as live', () => {
|
||||
expect(isJobLive(job('a', { status: 'running' }))).toBe(true)
|
||||
expect(isJobLive(job('b', { status: 'stopping' }))).toBe(true)
|
||||
expect(isJobLive(job('c', { status: 'completed' }))).toBe(false)
|
||||
expect(isJobLive(job('d', { status: 'killed' }))).toBe(false)
|
||||
expect(isJobLive(job('e', { status: 'failed' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('maps the five wire statuses to dot states and localized labels', () => {
|
||||
expect(jobDotState('running')).toBe('ongoing')
|
||||
expect(jobDotState('stopping')).toBe('warning')
|
||||
expect(jobDotState('completed')).toBe('done')
|
||||
expect(jobDotState('killed')).toBe('warning')
|
||||
expect(jobDotState('failed')).toBe('error')
|
||||
expect(jobStatusLabel('running', t)).toBe('jobStatusRunning')
|
||||
expect(jobStatusLabel('stopping', t)).toBe('jobStatusStopping')
|
||||
expect(jobStatusLabel('completed', t)).toBe('jobStatusCompleted')
|
||||
expect(jobStatusLabel('killed', t)).toBe('jobStatusKilled')
|
||||
expect(jobStatusLabel('failed', t)).toBe('jobStatusFailed')
|
||||
})
|
||||
|
||||
it('formats durations in at most two adjacent units', () => {
|
||||
expect(formatJobDuration(0, t)).toBe('0秒')
|
||||
expect(formatJobDuration(45_000, t)).toBe('45秒')
|
||||
expect(formatJobDuration(90_000, t)).toBe('1分30秒')
|
||||
expect(formatJobDuration(3_661_000, t)).toBe('1小时1分')
|
||||
// Negative or fractional input clamps to zero seconds.
|
||||
expect(formatJobDuration(-5, t)).toBe('0秒')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectNewJob', () => {
|
||||
const list = (jobsBySession: Record<string, SidebarJobView[]>): SidebarSessionList => ({
|
||||
current: 'root',
|
||||
byId: { root: { id: 'root', displayTitle: 'root' } },
|
||||
subagentsByParent: {},
|
||||
jobsBySession: jobsBySession,
|
||||
})
|
||||
|
||||
it('fires on EVERY new job id for the session (not just the first)', () => {
|
||||
expect(detectNewJob(list({}), list({ root: [job('bash-1')] }), 'root')).toBe(true)
|
||||
expect(detectNewJob(
|
||||
list({ root: [job('bash-1')] }),
|
||||
list({ root: [job('bash-1'), job('bash-2')] }),
|
||||
'root',
|
||||
)).toBe(true)
|
||||
})
|
||||
|
||||
it('stays quiet on settling, same ids, other sessions, or an absent mirror', () => {
|
||||
// Settling only mutates status, never adds ids.
|
||||
expect(detectNewJob(
|
||||
list({ root: [job('bash-1')] }),
|
||||
list({ root: [job('bash-1', { status: 'completed', finishedAt: 2_000 })] }),
|
||||
'root',
|
||||
)).toBe(false)
|
||||
expect(detectNewJob(list({ root: [job('bash-1')] }), list({ root: [job('bash-1')] }), 'root')).toBe(false)
|
||||
// Jobs owned by another session do not trigger the current one.
|
||||
expect(detectNewJob(list({}), list({ child: [job('bash-1')] }), 'root')).toBe(false)
|
||||
expect(detectNewJob(list({}), list({}), 'root')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,165 +0,0 @@
|
||||
/**
|
||||
* Pure-helper tests for the Subagent page's background-task section:
|
||||
* tree-membership collection, ordering, and status presentation mapping.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
collectTreeTasks,
|
||||
detectNewTask,
|
||||
formatTaskDuration,
|
||||
isTaskLive,
|
||||
orderTasks,
|
||||
taskDotState,
|
||||
taskStatusLabel,
|
||||
treeSessionIds,
|
||||
} from '../src/client/subagent-tasks.ts'
|
||||
import type { SidebarSessionList, SidebarSessionSummary, SidebarTaskStatus, SidebarTaskView } from '../src/context-types.ts'
|
||||
|
||||
/** The translator stub: renders duration templates like the real locale copy. */
|
||||
const templates: Record<string, string> = {
|
||||
taskDurationSeconds: '{seconds}秒',
|
||||
taskDurationMinutes: '{minutes}分{seconds}秒',
|
||||
taskDurationHours: '{hours}小时{minutes}分',
|
||||
}
|
||||
const t = (key: string, params?: Record<string, string | number>): string => {
|
||||
let text = templates[key] ?? key
|
||||
if (params !== undefined) {
|
||||
for (const [name, value] of Object.entries(params)) text = text.replaceAll(`{${name}}`, String(value))
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function summary(id: string, over: Partial<SidebarSessionSummary> = {}): SidebarSessionSummary {
|
||||
return { id, displayTitle: `title-${id}`, ...over }
|
||||
}
|
||||
|
||||
function task(id: string, over: Partial<SidebarTaskView> = {}): SidebarTaskView {
|
||||
return { id, kind: 'bash', label: `cmd ${id}`, status: 'running', startedAt: 1_000, ...over }
|
||||
}
|
||||
|
||||
describe('treeSessionIds', () => {
|
||||
it('includes the root and every subagent-origin session whose chain reaches it', () => {
|
||||
const byId = {
|
||||
root: summary('root'),
|
||||
child: summary('child', { origin: 'subagent', parentId: 'root' }),
|
||||
grand: summary('grand', { origin: 'subagent', parentId: 'child' }),
|
||||
orphan: summary('orphan', { origin: 'subagent', parentId: 'gone' }),
|
||||
other: summary('other', { origin: 'subagent', parentId: 'other-root' }),
|
||||
}
|
||||
const ids = treeSessionIds(byId, 'root')
|
||||
expect([...ids].sort()).toEqual(['child', 'grand', 'root'])
|
||||
})
|
||||
|
||||
it('fails soft on parent cycles and yields nothing without a root', () => {
|
||||
const byId = {
|
||||
root: summary('root'),
|
||||
a: summary('a', { origin: 'subagent', parentId: 'b' }),
|
||||
b: summary('b', { origin: 'subagent', parentId: 'a' }),
|
||||
}
|
||||
expect(treeSessionIds(byId, 'root').size).toBe(1)
|
||||
expect(treeSessionIds(byId, undefined).size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectTreeTasks', () => {
|
||||
it('collects tasks of the whole tree with owner titles, ignoring outside sessions', () => {
|
||||
const byId = {
|
||||
root: summary('root'),
|
||||
child: summary('child', { origin: 'subagent', parentId: 'root' }),
|
||||
}
|
||||
const tasksBySession = {
|
||||
root: [task('bash-1')],
|
||||
child: [task('bash-2', { status: 'completed', finishedAt: 2_000 })],
|
||||
stranger: [task('bash-9')],
|
||||
}
|
||||
const rows = collectTreeTasks(byId, tasksBySession, 'root')
|
||||
expect(rows.map(row => [row.ownerSessionId, row.ownerTitle, row.task.id]))
|
||||
.toEqual([['root', 'title-root', 'bash-1'], ['child', 'title-child', 'bash-2']])
|
||||
})
|
||||
|
||||
it('returns an empty list for an absent mirror or empty sets', () => {
|
||||
const byId = { root: summary('root') }
|
||||
expect(collectTreeTasks(byId, undefined, 'root')).toEqual([])
|
||||
expect(collectTreeTasks(byId, {}, 'root')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('orderTasks', () => {
|
||||
it('puts live rows first in start order, then settled rows newest-first', () => {
|
||||
const row = (id: string, status: SidebarTaskStatus, startedAt: number, finishedAt?: number) => ({
|
||||
ownerSessionId: 'root',
|
||||
ownerTitle: 'root',
|
||||
task: task(id, { status, startedAt, ...(finishedAt !== undefined ? { finishedAt } : {}) }),
|
||||
})
|
||||
const rows = [
|
||||
row('old-settled', 'completed', 1_000, 2_000),
|
||||
row('live-2', 'running', 4_000),
|
||||
row('new-settled', 'killed', 1_500, 1_800),
|
||||
row('live-1', 'stopping', 3_000),
|
||||
]
|
||||
expect(orderTasks(rows).map(r => r.task.id)).toEqual(['live-1', 'live-2', 'old-settled', 'new-settled'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('status presentation helpers', () => {
|
||||
it('treats running and stopping as live', () => {
|
||||
expect(isTaskLive(task('a', { status: 'running' }))).toBe(true)
|
||||
expect(isTaskLive(task('b', { status: 'stopping' }))).toBe(true)
|
||||
expect(isTaskLive(task('c', { status: 'completed' }))).toBe(false)
|
||||
expect(isTaskLive(task('d', { status: 'killed' }))).toBe(false)
|
||||
expect(isTaskLive(task('e', { status: 'failed' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('maps the five wire statuses to dot states and localized labels', () => {
|
||||
expect(taskDotState('running')).toBe('ongoing')
|
||||
expect(taskDotState('stopping')).toBe('warning')
|
||||
expect(taskDotState('completed')).toBe('done')
|
||||
expect(taskDotState('killed')).toBe('warning')
|
||||
expect(taskDotState('failed')).toBe('error')
|
||||
expect(taskStatusLabel('running', t)).toBe('taskStatusRunning')
|
||||
expect(taskStatusLabel('stopping', t)).toBe('taskStatusStopping')
|
||||
expect(taskStatusLabel('completed', t)).toBe('taskStatusCompleted')
|
||||
expect(taskStatusLabel('killed', t)).toBe('taskStatusKilled')
|
||||
expect(taskStatusLabel('failed', t)).toBe('taskStatusFailed')
|
||||
})
|
||||
|
||||
it('formats durations in at most two adjacent units', () => {
|
||||
expect(formatTaskDuration(0, t)).toBe('0秒')
|
||||
expect(formatTaskDuration(45_000, t)).toBe('45秒')
|
||||
expect(formatTaskDuration(90_000, t)).toBe('1分30秒')
|
||||
expect(formatTaskDuration(3_661_000, t)).toBe('1小时1分')
|
||||
// Negative or fractional input clamps to zero seconds.
|
||||
expect(formatTaskDuration(-5, t)).toBe('0秒')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectNewTask', () => {
|
||||
const list = (tasksBySession: Record<string, SidebarTaskView[]>): SidebarSessionList => ({
|
||||
current: 'root',
|
||||
byId: { root: { id: 'root', displayTitle: 'root' } },
|
||||
subagentsByParent: {},
|
||||
tasksBySession: tasksBySession,
|
||||
})
|
||||
|
||||
it('fires on EVERY new task id for the session (not just the first)', () => {
|
||||
expect(detectNewTask(list({}), list({ root: [task('bash-1')] }), 'root')).toBe(true)
|
||||
expect(detectNewTask(
|
||||
list({ root: [task('bash-1')] }),
|
||||
list({ root: [task('bash-1'), task('bash-2')] }),
|
||||
'root',
|
||||
)).toBe(true)
|
||||
})
|
||||
|
||||
it('stays quiet on settling, same ids, other sessions, or an absent mirror', () => {
|
||||
// Settling only mutates status, never adds ids.
|
||||
expect(detectNewTask(
|
||||
list({ root: [task('bash-1')] }),
|
||||
list({ root: [task('bash-1', { status: 'completed', finishedAt: 2_000 })] }),
|
||||
'root',
|
||||
)).toBe(false)
|
||||
expect(detectNewTask(list({ root: [task('bash-1')] }), list({ root: [task('bash-1')] }), 'root')).toBe(false)
|
||||
// Tasks owned by another session do not trigger the current one.
|
||||
expect(detectNewTask(list({}), list({ child: [task('bash-1')] }), 'root')).toBe(false)
|
||||
expect(detectNewTask(list({}), list({}), 'root')).toBe(false)
|
||||
})
|
||||
})
|
||||
+10
-10
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user