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:
Menghuan1918
2026-08-13 09:06:55 +08:00
parent ce052faee8
commit 1bfabbbfc2
29 changed files with 687 additions and 665 deletions
+24 -12
View File
@@ -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 }))
+37 -37
View File
@@ -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
View File
@@ -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
View File
@@ -61,17 +61,17 @@ export interface FsTextResult { kind: 'text'; content: string; truncated: boolea
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
/**
* 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 } : {}),
})),
+4 -4
View File
@@ -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 }) => (
+1 -1
View File
@@ -52,7 +52,7 @@ export const IconDiffOutline16 = ({ size = 16, className }: IconProps) => (
)
/**
* Stop glyph for the background-task kill button: a filled square in the
* Stop glyph for the background-job kill button: a filled square in the
* app's outline scale (16), the universal "halt this work" mark.
*/
export const IconStopOutline16 = ({ size = 16, className }: IconProps) => (
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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 })
}