chore: v0.8.0 — 任务管理页(子代理拓扑 + 后台命令底部输出 Dock、两击确认终止)

- tab 更名为任务管理:同页显示当前树全部后台命令(bash/pwsh 徽标、状态、时长、属主、退出码)
- 点击任务行在侧边栏底部共享 Dock 显示输出(sticky 终端式、自动跟随、单一轮询实例)
- 运行中任务两击确认强制终止;大量任务时列表保持紧凑、排序变动不跳动
- 新增 /sidebar/api tasks.output(非消费 peek,不偷模型 task_output 字节)与 tasks.kill 路由
- 修复任务镜像清空时的 React #300 hook 顺序崩溃(useCallback 移到提前 return 之前)+ 回归测试
This commit is contained in:
Menghuan1918
2026-08-12 23:04:30 +08:00
parent 27bc49830e
commit 8c521be26e
18 changed files with 1568 additions and 72 deletions
+272
View File
@@ -273,3 +273,275 @@
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
/* ── Background-task section (below the topology tree) ────────────────── */
.tasks {
margin-top: 10px;
border-top: 1px solid var(--dsw-alias-border-l2);
padding-top: 8px;
}
.tasksHeader {
display: flex;
align-items: center;
gap: 8px;
height: 26px;
padding: 0 2px;
}
.tasksTitle {
flex: 1;
min-width: 0;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-label-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tasksCount {
flex: none;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.tasksList {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
/* One task row; the whole row lights up on hover, the expandable target
stays transparent (the kill button rides on the row's right edge). */
.tasksRow {
display: flex;
align-items: center;
gap: 4px;
border-radius: 8px;
}
.tasksRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.tasksRowSettled {
opacity: 0.8;
}
/* The row whose output is shown in the bottom dock. */
.tasksRowSelected,
.tasksRowSelected:hover {
background: var(--dsw-alias-interactive-bg-active);
}
.tasksRowMain {
flex: 1;
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
padding: 6px 8px 6px 11px;
border: none;
border-radius: 8px;
background: transparent;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
text-align: left;
cursor: pointer;
outline: none;
}
.tasksRowMain:focus-visible {
background: var(--dsw-alias-interactive-bg-hover);
}
.tasksDot {
margin-top: 5px;
}
.tasksContent {
display: flex;
flex-direction: column;
min-width: 0;
gap: 1px;
}
/* The command line: a kind badge followed by the command itself in the code
face, like the terminal's live lines. */
.tasksLabelLine {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.tasksKind {
flex: none;
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 5px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 4px;
font: var(--dsw-font-xxxs-strong-11);
line-height: 14px;
color: var(--dsw-alias-label-tertiary);
}
.tasksLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--ds-font-family-code);
font-size: var(--dsw-font-xxxs-11-font-size);
line-height: var(--dsw-font-xxxs-11-line-height);
color: var(--dsw-alias-label-primary);
}
.tasksSecondary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.tasksKill {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
margin-right: 4px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.tasksKill:hover {
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
color: var(--dsw-alias-state-error-primary);
}
/* The armed (first-click) state: a filled confirm chip instead of the icon. */
.tasksKillArmed,
.tasksKillArmed:hover {
width: auto;
height: 20px;
padding: 0 8px;
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
color: var(--dsw-alias-state-error-primary);
font: var(--dsw-font-xxxs-strong-11);
white-space: nowrap;
}
.tasksKill:disabled {
opacity: 0.5;
cursor: default;
}
.tasksKillError {
flex: none;
margin-right: 4px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-state-error-primary);
}
/* ── The shared output dock (bottom of the page, sticky like a terminal) ── */
/* Direct child of the scrolling body, so it docks at the scrollport's bottom
edge while the task list scrolls above it; a solid background keeps the
tree from showing through. */
.tasksPane {
position: sticky;
bottom: 0;
z-index: 1;
margin-top: 4px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-base);
box-shadow: 0 -6px 12px -8px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.tasksPaneHeader {
display: flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 4px 0 10px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.tasksPaneDot {
flex: none;
}
.tasksPaneLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--ds-font-family-code);
font-size: var(--dsw-font-xxxs-11-font-size);
line-height: var(--dsw-font-xxxs-11-line-height);
color: var(--dsw-alias-label-primary);
}
.tasksPaneStatus {
flex: none;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.tasksPaneClose {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
border-radius: 5px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.tasksPaneClose:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.tasksPanePre {
margin: 0;
max-height: 200px;
overflow: auto;
padding: 6px 10px;
font-family: var(--ds-font-family-code);
font-size: var(--dsw-font-xxxs-11-font-size);
line-height: 1.5;
color: var(--dsw-alias-label-primary);
white-space: pre-wrap;
word-break: break-word;
}
.tasksPaneHint {
padding: 8px 10px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.tasksPaneError {
color: var(--dsw-alias-state-error-primary);
}
+339 -60
View File
@@ -27,11 +27,13 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import type {
Context,
SidebarSessionList,
SidebarSessionSummary,
SidebarSubagentAddress,
SidebarSubagentCatalog,
SidebarSubagentChildEntry,
SidebarSubagentDiagnosticEntry,
SidebarTaskView,
} from '../context-types.ts'
import {
collectBranchIds,
@@ -39,6 +41,17 @@ import {
rootAncestor,
} from './subagent-detect.ts'
import { lastActivity } from './subagent-activity.ts'
import {
collectTreeTasks,
formatTaskDuration,
isTaskLive,
orderTasks,
taskDotState,
taskStatusLabel,
type TreeTask,
} from './subagent-tasks.ts'
import { api, type TaskOutputResult } from './api.ts'
import { IconStopOutline16 } from './icons.tsx'
import { t } from './locales.ts'
import css from './SubagentView.module.css'
@@ -46,6 +59,10 @@ import css from './SubagentView.module.css'
const POLL_MS = 3000
/** Preview cap of one tool-call argument line. */
const ARGS_PREVIEW = 60
/** Refresh cadence of an expanded task-output panel while its task runs. */
const TASK_POLL_MS = 2000
/** How long the kill button stays armed before it needs re-confirming. */
const TASK_KILL_ARM_MS = 3000
/** The direct subagent children of one parent (durable `origin` rows). */
function directChildren(
@@ -340,6 +357,259 @@ function CatalogRows({
)
}
/**
* The shared output dock of the tasks section: ONE pane at the bottom of the
* sidebar body (sticky, terminal-like) shows the SELECTED task's FULL
* accumulated output via the host's NON-CONSUMING peek (`tasks.output`),
* refreshed every {@link TASK_POLL_MS} while the task runs and the page is
* visible. The model's own `task_output` cursor is never touched, so
* watching a stream cannot steal the agent's bytes. A single dock — not a
* panel per row — keeps the task list compact and stable when many tasks
* are running.
*/
function TaskOutputPane(props: {
ownerSessionId: string
task: SidebarTaskView
/** The page is visible (active tab + open panel): skip polling otherwise. */
active: boolean
onClose: () => void
}) {
const { ownerSessionId, task, active, onClose } = props
const [state, setState] = useState<'loading' | TaskOutputResult | 'error'>('loading')
const controllerRef = useRef<AbortController | undefined>(undefined)
const preRef = useRef<HTMLPreElement>(null)
const load = useCallback(async (): Promise<void> => {
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
try {
const result = await api.taskOutput({ sessionId: ownerSessionId }, task.id, controller.signal)
setState(result)
} catch {
// A newer pull aborted this one, or the wire failed: keep the last
// known output; only a dock that never loaded anything shows an error.
setState(current => (current === 'loading' ? 'error' : current))
}
}, [ownerSessionId, task.id])
useEffect(() => {
void load()
if (!active || !isTaskLive(task)) return
const timer = window.setInterval(() => { void load() }, TASK_POLL_MS)
return () => { window.clearInterval(timer) }
}, [load, active, task.status])
useEffect(() => () => { controllerRef.current?.abort() }, [])
// Terminal-tail behavior: while the task runs, each refresh pins the view
// to the newest output; a settled dock leaves scrolling to the reader.
useEffect(() => {
if (!isTaskLive(task) || typeof state !== 'object' || state.text.length === 0) return
const pre = preRef.current
if (pre !== null) pre.scrollTop = pre.scrollHeight
}, [state, task.status])
return (
<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}` : ''}
</span>
<button
type="button"
className={css.tasksPaneClose}
aria-label={t('close')}
title={t('close')}
onClick={onClose}
>
<IconStopOutline16 size={10} />
</button>
</div>
{state === 'loading' && <div className={css.tasksPaneHint}>{t('loading')}</div>}
{state === 'error' && (
<div className={`${css.tasksPaneHint} ${css.tasksPaneError}`}>{t('taskOutputError')}</div>
)}
{typeof state === 'object' && (
<>
{state.text.length > 0
? <pre ref={preRef} className={css.tasksPanePre}>{state.text}</pre>
: <div className={css.tasksPaneHint}>{t('taskNoOutput')}</div>}
{state.truncated && <div className={css.tasksPaneHint}>{t('taskOutputTruncated')}</div>}
</>
)}
</div>
)
}
/**
* The background-task section of the Subagent page: every task of the whole
* current tree (main agent + subagents, owner-labeled), fed by the harness
* `session/tasks` push mirror. Clicking a row feeds its output to the shared
* bottom dock (non-consuming peek); live rows carry a two-click-confirm
* kill button. Renders nothing while the tree has no tasks.
*/
function TasksSection(props: {
byId: SidebarSessionList['byId']
tasksBySession: SidebarSessionList['tasksBySession']
rootId: string | undefined
/** The page is visible (active tab + open panel): skip polling otherwise. */
active: boolean
}) {
const { byId, tasksBySession, rootId, active } = props
const rows = useMemo(
() => orderTasks(collectTreeTasks(byId, tasksBySession, rootId)),
[byId, tasksBySession, rootId],
)
const [selectedId, setSelectedId] = useState<string | undefined>(undefined)
const [armedId, setArmedId] = useState<string | undefined>(undefined)
const [killingId, setKillingId] = useState<string | undefined>(undefined)
const [killErrorId, setKillErrorId] = useState<string | undefined>(undefined)
// The duration clock only runs while a live row is on screen.
const [now, setNow] = useState(() => Date.now())
const selectedRow = useMemo(
() => (selectedId === undefined ? undefined : rows.find(row => row.task.id === selectedId)),
[rows, selectedId],
)
const liveCount = useMemo(
() => rows.reduce((count, row) => count + (isTaskLive(row.task) ? 1 : 0), 0),
[rows],
)
const multiOwner = useMemo(
() => new Set(rows.map(row => row.ownerSessionId)).size > 1,
[rows],
)
// The kill button stays armed only briefly; a stray click must never kill.
useEffect(() => {
if (armedId === undefined) return
const timer = window.setTimeout(() => { setArmedId(undefined) }, TASK_KILL_ARM_MS)
return () => { window.clearTimeout(timer) }
}, [armedId])
useEffect(() => {
if (liveCount === 0) return
setNow(Date.now())
const timer = window.setInterval(() => { setNow(Date.now()) }, 1_000)
return () => { window.clearInterval(timer) }
}, [liveCount])
// The docked output pane follows its task: when the selected task leaves
// the mirror (settled and dropped, or the tree switched), close the dock.
useEffect(() => {
if (selectedId !== undefined && selectedRow === undefined) setSelectedId(undefined)
}, [selectedId, selectedRow])
// NOTE: every hook must live ABOVE the empty-state return — a hook below it
// would flip this component's hook count when the mirror empties and crash
// React with "Rendered fewer hooks than expected" (the #300 regression).
const kill = useCallback(async (row: TreeTask): Promise<void> => {
setKillingId(row.task.id)
setKillErrorId(undefined)
try {
await api.taskKill({ sessionId: row.ownerSessionId }, row.task.id)
} catch {
setKillErrorId(row.task.id)
} finally {
setKillingId(undefined)
setArmedId(undefined)
}
}, [])
if (rows.length === 0) return null
const countLabel = liveCount > 0
? t('tasksCountRunning', { count: rows.length, running: liveCount })
: t('tasksCount', { count: rows.length })
return (
<>
<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>
</div>
<ul className={css.tasksList} aria-label={t('tasks')}>
{rows.map((row) => {
const { task } = row
const live = isTaskLive(task)
const selected = selectedId === task.id
const armed = armedId === task.id
const killing = killingId === task.id
const killFailed = killErrorId === task.id
const elapsed = live
? now - task.startedAt
: (task.finishedAt ?? task.startedAt) - task.startedAt
const secondary = [
...(multiOwner ? [row.ownerTitle] : []),
taskStatusLabel(task.status, t),
...(task.detail !== undefined && task.detail !== '' ? [task.detail] : []),
formatTaskDuration(elapsed, t),
].filter(Boolean).join(' · ')
return (
<li
key={task.id}
className={clsx(
css.tasksRow,
!live && css.tasksRowSettled,
selected && css.tasksRowSelected,
)}
>
<button
type="button"
className={css.tasksRowMain}
aria-pressed={selected}
aria-label={`${task.label} ${secondary}`}
onClick={() => { setSelectedId(selected ? undefined : task.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>
</span>
<span className={css.tasksSecondary}>{secondary}</span>
</span>
</button>
{task.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')}
disabled={killing}
onClick={(event) => {
event.stopPropagation()
if (armed) void kill(row)
else setArmedId(task.id)
}}
>
{armed ? t('taskKillConfirm') : <IconStopOutline16 size={12} />}
</button>
)}
{killFailed && <span className={css.tasksKillError}>{t('taskKillError')}</span>}
</li>
)
})}
</ul>
</section>
{selectedRow !== undefined && (
<TaskOutputPane
ownerSessionId={selectedRow.ownerSessionId}
task={selectedRow.task}
active={active}
onClose={() => { setSelectedId(undefined) }}
/>
)}
</>
)
}
/**
* The sidebar's Subagent topology page.
* @param props - current session id, whether the page is actually visible
@@ -515,69 +785,78 @@ export function SubagentView(props: {
<div
ref={bodyRef}
className={css.subagentBody}
role="tree"
aria-label={t('subagent')}
aria-busy={summaryBackedLoading || undefined}
onKeyDown={onTreeKeyDown}
>
{rootId !== undefined && rootSummary !== undefined && (
<div
role="treeitem"
tabIndex={0}
aria-level={0}
aria-label={`${rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} ${t('subagentMainAgent')}`}
aria-current={rootId === sessionId ? 'true' : undefined}
className={clsx(css.subagentRow, rootId === sessionId && css.subagentRowActive)}
onClick={openMain}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
event.stopPropagation()
openMain()
}
}}
>
<StateDot
state={rootSummary.running === true ? 'ongoing' : 'done'}
className={css.subagentDot}
/>
<span className={css.subagentContent}>
<span className={css.subagentLabel}>
{rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')}
</span>
<span className={css.subagentSecondary}>
{`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`}
</span>
</span>
</div>
)}
{rootId !== undefined && (
<div className={css.subagentChildren} role="group" aria-busy={summaryBackedLoading || undefined}>
{summaryBackedLoading && (
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
)}
{!summaryBackedLoading && (
<CatalogRows
parentSessionId={rootId}
catalog={rootCatalog}
catalogs={catalogs}
byId={byId}
level={1}
currentSessionId={sessionId}
active={active}
ctx={ctx}
openChild={openChild}
refresh={refresh}
<div
role="tree"
aria-label={t('subagent')}
aria-busy={summaryBackedLoading || undefined}
>
{rootId !== undefined && rootSummary !== undefined && (
<div
role="treeitem"
tabIndex={0}
aria-level={0}
aria-label={`${rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} ${t('subagentMainAgent')}`}
aria-current={rootId === sessionId ? 'true' : undefined}
className={clsx(css.subagentRow, rootId === sessionId && css.subagentRowActive)}
onClick={openMain}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
event.stopPropagation()
openMain()
}
}}
>
<StateDot
state={rootSummary.running === true ? 'ongoing' : 'done'}
className={css.subagentDot}
/>
)}
</div>
)}
{readyEmpty && (
<div className={css.subagentEmpty}>
<div>{t('subagentEmpty')}</div>
<div className={css.subagentEmptyHint}>{t('subagentEmptyDesc')}</div>
</div>
)}
<span className={css.subagentContent}>
<span className={css.subagentLabel}>
{rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')}
</span>
<span className={css.subagentSecondary}>
{`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`}
</span>
</span>
</div>
)}
{rootId !== undefined && (
<div className={css.subagentChildren} role="group" aria-busy={summaryBackedLoading || undefined}>
{summaryBackedLoading && (
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
)}
{!summaryBackedLoading && (
<CatalogRows
parentSessionId={rootId}
catalog={rootCatalog}
catalogs={catalogs}
byId={byId}
level={1}
currentSessionId={sessionId}
active={active}
ctx={ctx}
openChild={openChild}
refresh={refresh}
/>
)}
</div>
)}
{readyEmpty && (
<div className={css.subagentEmpty}>
<div>{t('subagentEmpty')}</div>
<div className={css.subagentEmptyHint}>{t('subagentEmptyDesc')}</div>
</div>
)}
</div>
<TasksSection
byId={byId}
tasksBySession={list.tasksBySession}
rootId={rootId}
active={active}
/>
</div>
</div>
)
+24
View File
@@ -8,6 +8,7 @@
*/
import { encodeHtmlUrl } from '../html-route.ts'
import type { BrowserProbeResult } from './browser.ts'
import type { SidebarTaskStatus } from '../context-types.ts'
/** One wire failure. */
export class SidebarApiError extends Error {
@@ -60,6 +61,15 @@ export interface FsTextResult { kind: 'text'; content: string; truncated: boolea
* `head` carries the first bytes (base64) for viewer detect sniffing. */
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
/** One tasks.output response: the peeked full text (capped) plus the wire status. */
export interface TaskOutputResult {
text: string
/** True when the host capped the text at its output limit. */
truncated: boolean
status: SidebarTaskStatus
detail?: string
}
async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
let response: Response
try {
@@ -145,6 +155,20 @@ export const api = {
/** Release an agent terminal by uuid (tab closed while WS was down). */
agentPtyClose: (uuid: string) =>
call<{ ok: true }>('agent-pty.close', { uuid }),
/**
* Full accumulated output of one background task (the host's NON-CONSUMING
* peek — the model's task_output cursor and report bit are untouched, so a
* human watching a stream never steals the agent's bytes). The scope MUST
* be the task's OWNER session (the tasks registry fences by session id).
*/
taskOutput: (scope: SessionScope, id: string, signal?: AbortSignal) =>
call<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, {
id,
...(reason !== undefined ? { reason } : {}),
})),
/** Read the side card preferences (plugin-global, no session scope). */
settingsGet: () =>
call<{ value?: unknown; revision?: number }>('settings.get', {}),
+10
View File
@@ -51,6 +51,16 @@ export const IconDiffOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/**
* Stop glyph for the background-task kill button: a filled square in the
* app's outline scale (16), the universal "halt this work" mark.
*/
export const IconStopOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" stroke="none" />
</svg>
)
// ── File-viewer inventory glyphs (Side card settings page) ────────────────
/** Image viewer glyph: a picture frame with a sun and a mountain. */
+46 -8
View File
@@ -119,8 +119,8 @@ const zh = {
nextSlide: '下一页',
zoom: '缩放',
zoomHint: 'Alt + 滚轮',
settingsSubagentTitle: '检测到子代理时自动展开子代理页面',
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开子代理页面;关闭后需手动打开',
settingsSubagentTitle: '检测到子代理时自动展开任务管理页',
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开任务管理页;关闭后需手动打开',
settingsToolsTitle: '为模型注入终端工具',
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
settingsBottomTerminalTitle: '底部面板首次展开自动开终端',
@@ -165,8 +165,8 @@ const zh = {
browserEmbedBlocked: '{host} 拒绝了嵌入请求',
browserEmbedBlockedDesc: '该站点通过 X-Frame-Options / frame-ancestors 禁止在其它页面中显示,无法在侧边栏内加载。可在浏览器中直接打开',
browserEmbedAnyway: '仍然加载',
subagent: '子代理',
openSubagent: '子代理',
subagent: '任务管理',
openSubagent: '任务管理',
subagentMainAgent: '主代理',
subagentEmpty: '暂无子代理',
subagentEmptyDesc: '当前主代理派生的子代理将显示在这里',
@@ -180,6 +180,25 @@ const zh = {
subagentDiagUnsupported: '不支持的条目',
subagentDiagUnavailable: '不可用',
subagentThinking: '思考中…',
tasks: '后台命令',
tasksCount: '{count} 个后台命令',
tasksCountRunning: '{count} 个后台命令 · {running} 运行中',
taskStatusRunning: '运行中',
taskStatusStopping: '终止中',
taskStatusCompleted: '已完成',
taskStatusKilled: '已终止',
taskStatusFailed: '失败',
taskDurationSeconds: '{seconds} 秒',
taskDurationMinutes: '{minutes} 分 {seconds} 秒',
taskDurationHours: '{hours} 小时 {minutes} 分',
taskViewOutput: '查看输出',
taskHideOutput: '收起输出',
taskNoOutput: '暂无输出',
taskOutputTruncated: '输出过长,已截断显示',
taskOutputError: '输出读取失败',
taskKill: '终止',
taskKillConfirm: '再次点击确认终止',
taskKillError: '终止失败',
}
const en: Record<keyof typeof zh, string> = {
@@ -296,8 +315,8 @@ const en: Record<keyof typeof zh, string> = {
nextSlide: 'Next',
zoom: 'Zoom',
zoomHint: 'Alt + wheel',
settingsSubagentTitle: 'Auto-open the Subagent page when a subagent appears',
settingsSubagentDesc: 'Expand the side card and open the Subagent page when the current conversation spawns a new subagent; turn off to open it manually',
settingsSubagentTitle: 'Auto-open the Tasks page when a subagent appears',
settingsSubagentDesc: 'Expand the side card and open the Tasks page when the current conversation spawns a new subagent; turn off to open it manually',
settingsToolsTitle: 'Inject terminal tools for the model',
settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)',
settingsBottomTerminalTitle: 'Auto-open a terminal on the bottom panel\'s first expansion',
@@ -342,8 +361,8 @@ const en: Record<keyof typeof zh, string> = {
browserEmbedBlocked: '{host} refused to be embedded',
browserEmbedBlockedDesc: 'The site forbids being displayed inside other pages (X-Frame-Options / frame-ancestors), so it cannot load in the sidebar. Open it directly in your browser instead.',
browserEmbedAnyway: 'Load anyway',
subagent: 'Subagents',
openSubagent: 'Subagents',
subagent: 'Tasks',
openSubagent: 'Tasks',
subagentMainAgent: 'Main agent',
subagentEmpty: 'No subagents',
subagentEmptyDesc: 'Subagents spawned under the main agent will appear here',
@@ -357,6 +376,25 @@ const en: Record<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',
taskOutputTruncated: 'Output truncated',
taskOutputError: 'Failed to read output',
taskKill: 'Kill',
taskKillConfirm: 'Click again to confirm kill',
taskKillError: 'Kill failed',
}
/** Translate a copy key in the browser's language (zh → zh, else en). */
+144
View File
@@ -0,0 +1,144 @@
/**
* Pure derivations for the Subagent page's background-task section. Kept
* framework-free so the node test environment can unit-test them: the task
* rows arrive through the harness `session/tasks` push mirror
* (`tasksBySession` in the sessions list feed) — nothing here issues
* requests, and the row ordering / status mapping mirror the official
* ui-task header list.
*/
import type {
SidebarSessionList,
SidebarSessionSummary,
SidebarTaskStatus,
SidebarTaskView,
} from '../context-types.ts'
import type { CopyKey } from './locales.ts'
/** One row of the tasks section: the task plus its owning session's title. */
export interface TreeTask {
ownerSessionId: string
ownerTitle: string
task: SidebarTaskView
}
/** Whether the registry still holds the task open (its duration ticks). */
export function isTaskLive(task: SidebarTaskView): boolean {
return task.status === 'running' || task.status === 'stopping'
}
/**
* Every session id of the topology tree rooted at `rootId` (the root plus
* each session whose uninterrupted subagent-origin chain reaches it — same
* lineage semantics as {@link countSubagentDescendants}; cycles fail soft).
* Sessions outside the tree (orphans, other trees) are excluded, so the
* tasks section never shows foreign work.
*/
export function treeSessionIds(
byId: SidebarSessionList['byId'],
rootId: string | undefined,
): Set<string> {
const ids = new Set<string>()
if (rootId === undefined) return ids
for (const summary of Object.values(byId)) {
const seen = new Set<string>()
let current: SidebarSessionSummary | undefined = summary
let reachesRoot = false
while (current !== undefined && !seen.has(current.id)) {
seen.add(current.id)
if (current.id === rootId) {
reachesRoot = true
break
}
if (current.origin !== 'subagent' || current.parentId === undefined) break
current = byId[current.parentId]
}
if (reachesRoot) ids.add(summary.id)
}
return ids
}
/**
* Collect the background tasks of the whole current tree, owner-labeled.
* Sessions without a mirror entry contribute nothing; an absent mirror
* (runtime older than the tasks feed) yields an empty list.
*/
export function collectTreeTasks(
byId: SidebarSessionList['byId'],
tasksBySession: Readonly<Record<string, readonly SidebarTaskView[]>> | undefined,
rootId: string | undefined,
): TreeTask[] {
const rows: TreeTask[] = []
if (tasksBySession === undefined) return rows
for (const sessionId of treeSessionIds(byId, rootId)) {
const tasks = tasksBySession[sessionId]
if (tasks === undefined || tasks.length === 0) continue
const ownerTitle = byId[sessionId]?.displayTitle ?? sessionId
for (const task of tasks) rows.push({ ownerSessionId: sessionId, ownerTitle, task })
}
return rows
}
/**
* Live rows first in start order, then settled rows newest-first (mirror of
* the official ui-task ordering); a tie falls back to start order so the
* sort never depends on the host's map iteration.
*/
export function orderTasks(rows: readonly TreeTask[]): TreeTask[] {
return [...rows].sort((left, right) => {
const liveLeft = isTaskLive(left.task)
if (liveLeft !== isTaskLive(right.task)) return liveLeft ? -1 : 1
if (liveLeft) return left.task.startedAt - right.task.startedAt
const finished = (right.task.finishedAt ?? right.task.startedAt) - (left.task.finishedAt ?? left.task.startedAt)
return finished !== 0 ? finished : left.task.startedAt - right.task.startedAt
})
}
/** The sidebar's StateDot states for the five wire statuses. */
export type TaskDotState = 'ongoing' | 'warning' | 'done' | 'error'
/**
* Status marker semantics. `stopping` and `killed` share the attention
* color: both mean the work ended (or is ending) on request rather than on
* its own.
*/
export function taskDotState(status: SidebarTaskStatus): TaskDotState {
switch (status) {
case 'running': return 'ongoing'
case 'stopping': return 'warning'
case 'completed': return 'done'
case 'killed': return 'warning'
case 'failed': return 'error'
}
}
/** Human status word of one wire status (localized through the passed translator). */
export function taskStatusLabel(
status: SidebarTaskStatus,
t: (key: CopyKey, params?: Record<string, string | 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')
}
}
/**
* Elapsed time in at most two adjacent units (mirror of the official
* ui-task duration wording). A background task that outlives an hour is
* already exceptional, so hours is the widest unit.
*/
export function formatTaskDuration(
elapsedMs: number,
t: (key: CopyKey, params?: Record<string, string | number>) => string,
): string {
const total = Math.max(0, Math.floor(elapsedMs / 1_000))
const seconds = total % 60
const minutes = Math.floor(total / 60) % 60
const hours = Math.floor(total / 3_600)
if (hours > 0) return t('taskDurationHours', { hours, minutes })
if (minutes > 0) return t('taskDurationMinutes', { minutes, seconds })
return t('taskDurationSeconds', { seconds })
}