feat(web): list background tasks in the session header

The task registry has run every background bash, pwsh, pty-send, and
one-shot subagent since it landed, but only the model could read it: a
human at the Web client could not see that a build was running, tell a
finished task from a stuck one, or find its outcome anywhere but the
`run_in_background` tool card that printed an id and never updated.

Task state now reaches the browser as one whole-snapshot `session/tasks`
mux frame per session, pushed at every registry commit that changes what
that session can see. `TaskService` gains `onTasksChanged`, which is
owner-granular because owner-disposal removal is a change no per-task
record can express. The carrier reads the exact owner the listener hands
it, so a push stays correct while that scope tears down, and reads the
baseline through the non-resuming `ctx.agents.get` so listing never
revives a cold session. The client keeps a last-wins mirror on
`SessionListState`, and a new `dsh-client-ui-task` package renders it
beside the subagent catalog — rendering nothing at all until the session
has a task, so an ordinary conversation grows no new chrome.

Streamed per-task output and human-initiated cancellation are separate
phases; the note records why neither has to undo this channel, and why
no Web path may call the consuming `ctx.tasks.read()`.
This commit is contained in:
Yichen Jiang
2026-08-08 23:29:41 +08:00
parent 22609ea425
commit eab0aeb9db
93 changed files with 2130 additions and 68 deletions

View File

@@ -0,0 +1,116 @@
.root {
position: relative;
}
.trigger {
display: inline-flex;
align-items: center;
gap: 3px;
min-height: 28px;
padding: 3px 2px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
cursor: pointer;
}
.trigger:hover,
.trigger:focus-visible {
color: var(--dsw-alias-label-secondary);
}
.trigger svg {
transition: transform 120ms ease;
}
.triggerOpen {
transform: rotate(180deg);
}
.triggerDot {
flex: none;
}
.count {
margin: 0 5px;
}
.menu {
position: absolute;
top: calc(100% + 5px);
left: 0;
z-index: 100;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 1px;
width: 336px;
max-width: min(400px, calc(100vw - 32px));
max-height: min(420px, calc(100vh - 140px));
margin: 0;
padding: 4px;
overflow: auto;
list-style: none;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-specific-menu);
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
box-shadow: var(--dsw-shadow-lv3);
}
.row {
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
width: 100%;
min-height: 32px;
padding: 6px 8px;
border-radius: 8px;
color: var(--dsw-alias-label-primary);
font-size: 13px;
line-height: 18px;
}
.rowSettled {
color: var(--dsw-alias-label-tertiary);
}
.rowDot {
flex: none;
}
.kind {
flex: none;
padding: 0 6px;
border-radius: 5px;
background: var(--dsw-alias-fill-l2);
color: var(--dsw-alias-label-secondary);
font-size: 11px;
line-height: 18px;
}
.label {
flex: 1;
min-width: 0;
overflow: hidden;
font-family: var(--dsw-font-mono);
white-space: nowrap;
text-overflow: ellipsis;
}
.status,
.duration {
flex: none;
color: var(--dsw-alias-label-tertiary);
font-size: 11px;
line-height: 18px;
}
.duration {
font-variant-numeric: tabular-nums;
}

View File

@@ -0,0 +1,185 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import type { TaskView } from '@deepseek-ai/dsh-client-runtime/client'
import { IconChevronDownOutline14, StateDot, type StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { NS } from './locales.ts'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import css from './TaskListAction.module.css'
/** Full props for the session-header background-task action. */
export type TaskListActionProps =
PropsRuntime<'conversation.session.header.actions'> & PropsLocale<typeof NS>
/** Stable empty list so a session with no tasks keeps one array identity. */
const NO_TASKS: readonly TaskView[] = []
/** A task the registry still holds open, and whose duration therefore ticks. */
function isLive(task: TaskView): boolean {
return task.status === 'running' || task.status === 'stopping'
}
/** Closed-union exhaustiveness fence for the wire status set. */
/* v8 ignore next 3 -- closed-union backstop; only reached if a status is forged */
function assertNever(value: never): never {
throw new Error(`unhandled task status: ${JSON.stringify(value)}`)
}
/**
* 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.
*/
function dotState(status: TaskView['status']): StateDotState {
switch (status) {
case 'running': return 'ongoing'
case 'stopping': return 'warning'
case 'completed': return 'done'
case 'killed': return 'warning'
case 'failed': return 'error'
/* v8 ignore next -- closed wire status union */
default: return assertNever(status)
}
}
/** Human status word for the row and its accessible name. */
function statusLabel(status: TaskView['status'], t: TranslateNS<typeof NS>): string {
switch (status) {
case 'running': return t('status.running')
case 'stopping': return t('status.stopping')
case 'completed': return t('status.completed')
case 'killed': return t('status.killed')
case 'failed': return t('status.failed')
/* v8 ignore next -- closed wire status union */
default: return assertNever(status)
}
}
/**
* Elapsed time in at most two adjacent units. A background task that outlives
* an hour is already exceptional, so hours is the widest unit — beyond that the
* figure stays in hours rather than growing a day/month vocabulary no producer
* currently reaches.
*/
function formatDuration(elapsedMs: number, t: TranslateNS<typeof NS>): 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('duration.hours', { hours, minutes })
if (minutes > 0) return t('duration.minutes', { minutes, seconds })
return t('duration.seconds', { seconds })
}
/**
* Live rows first in start order, then settled rows newest-first. Two tasks
* that settled in the same millisecond fall back to start order, so the sort
* never depends on the host's map iteration.
*/
function ordered(tasks: readonly TaskView[]): TaskView[] {
return [...tasks].sort((left, right) => {
const liveLeft = isLive(left)
if (liveLeft !== isLive(right)) return liveLeft ? -1 : 1
if (liveLeft) return left.startedAt - right.startedAt
const finished = (right.finishedAt ?? right.startedAt) - (left.finishedAt ?? left.startedAt)
return finished !== 0 ? finished : left.startedAt - right.startedAt
})
}
/**
* Session-header entry point for this session's background tasks. It renders
* nothing at all until the session has at least one task, so an ordinary
* conversation never grows a control for a capability it is not using.
* @param props - runtime slot currency plus the namespace translator.
* @returns the trigger and its popover list, or null when there is nothing to show.
*/
export function TaskListAction({ sessionId, useSessions, t }: TaskListActionProps) {
const tasks = useSessions(state => state.tasksBySession[sessionId]) ?? NO_TASKS
const [open, setOpen] = useState(false)
const [now, setNow] = useState(() => Date.now())
const rootRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const rows = useMemo(() => ordered(tasks), [tasks])
const liveCount = useMemo(() => tasks.filter(isLive).length, [tasks])
useEffect(() => {
if (!open) return
const closeOutside = (event: PointerEvent): void => {
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) {
setOpen(false)
}
}
document.addEventListener('pointerdown', closeOutside)
return () => { document.removeEventListener('pointerdown', closeOutside) }
}, [open])
// The clock only runs while an open list is showing something that moves.
useEffect(() => {
if (!open || liveCount === 0) return
setNow(Date.now())
const timer = setInterval(() => { setNow(Date.now()) }, 1_000)
return () => { clearInterval(timer) }
}, [open, liveCount])
// The last task disappearing removes this control; close first so focus does
// not vanish from an unmounting node.
useEffect(() => {
if (tasks.length === 0 && open) setOpen(false)
}, [tasks.length, open])
if (tasks.length === 0) return null
const countKey = liveCount > 0
? (liveCount === 1 ? 'count.live.one' : 'count.live.other')
: (tasks.length === 1 ? 'count.idle.one' : 'count.idle.other')
const countLabel = t(countKey, { count: liveCount > 0 ? liveCount : tasks.length })
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key !== 'Escape' || !open) return
event.preventDefault()
setOpen(false)
triggerRef.current?.focus()
}
return (
<div ref={rootRef} className={css.root} onKeyDown={onKeyDown}>
<button
ref={triggerRef}
type="button"
className={css.trigger}
aria-expanded={open}
aria-label={countLabel}
onClick={() => { setOpen(current => !current) }}
>
{liveCount > 0 ? <StateDot state="ongoing" className={css.triggerDot} /> : null}
<span className={css.count}>{countLabel}</span>
<IconChevronDownOutline14 className={open ? css.triggerOpen : undefined} />
</button>
{open
? (
<ul className={css.menu} aria-label={t('list.aria')}>
{rows.map((task) => {
const live = isLive(task)
const elapsed = live ? now - task.startedAt : (task.finishedAt ?? task.startedAt) - task.startedAt
const duration = formatDuration(elapsed, t)
const status = statusLabel(task.status, t)
return (
<li key={task.id} className={live ? css.row : `${css.row} ${css.rowSettled}`}>
<StateDot state={dotState(task.status)} className={css.rowDot} />
<span className={css.kind}>{task.kind}</span>
<span className={css.label} title={task.label}>{task.label}</span>
<span className={css.status}>{task.detail ?? status}</span>
<span
className={css.duration}
title={t(live ? 'duration.title.live' : 'duration.title.done', { duration })}
>
{duration}
</span>
</li>
)
})}
</ul>
)
: null}
</div>
)
}

View File

@@ -0,0 +1,40 @@
/**
* Background-task plugin, browser half: contributes one session-header action
* that renders this session's `ctx.tasks` records. The data arrives entirely
* through the `tasksBySession` list mirror, so the plugin issues no RPC and
* holds no state of its own beyond popover visibility.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { TaskListAction } from './TaskListAction.tsx'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { en, NS, zh, type TaskKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Background-task list copy. */
'task': TaskKey
}
}
export type { TaskListActionProps } from './TaskListAction.tsx'
/** Required services for locale registration and header-slot contribution. */
export const inject = ['sessions', 'slots', 'locale']
/**
* Client plugin body: register the dictionaries and the header action.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-task: dictionaries')
ctx.slots.inject(
'conversation.session.header.actions',
() => ctx.slots.register({
name: 'conversation.session.header.actions',
id: 'task-list',
// After the subagent catalog: session lineage reads before process work.
order: 20,
locale: NS,
}, TaskListAction),
)
}

View File

@@ -0,0 +1,45 @@
/** `task` namespace dictionaries. */
/** Dictionary namespace owned by this plugin. */
export const NS = 'task'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'count.live.one': '{count} 个后台任务运行中',
'count.live.other': '{count} 个后台任务运行中',
'count.idle.one': '{count} 个后台任务',
'count.idle.other': '{count} 个后台任务',
'list.aria': '后台任务',
'status.running': '运行中',
'status.stopping': '正在停止',
'status.completed': '已完成',
'status.killed': '已取消',
'status.failed': '已失败',
'duration.seconds': '{seconds}秒',
'duration.minutes': '{minutes}分{seconds}秒',
'duration.hours': '{hours}小时{minutes}分',
'duration.title.live': '已运行 {duration}',
'duration.title.done': '耗时 {duration}',
} as const
/** English dictionary, key-identical to the Chinese source of truth. */
export const en: Record<TaskKey, string> = {
'count.live.one': '{count} background task running',
'count.live.other': '{count} background tasks running',
'count.idle.one': '{count} background task',
'count.idle.other': '{count} background tasks',
'list.aria': 'Background tasks',
'status.running': 'running',
'status.stopping': 'stopping',
'status.completed': 'completed',
'status.killed': 'cancelled',
'status.failed': 'failed',
'duration.seconds': '{seconds}s',
'duration.minutes': '{minutes}m {seconds}s',
'duration.hours': '{hours}h {minutes}m',
'duration.title.live': 'Running for {duration}',
'duration.title.done': 'Took {duration}',
}
/** Key domain of the `task` namespace (zh is the source of truth). */
export type TaskKey = keyof typeof zh

View File

@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'

View File

@@ -0,0 +1,9 @@
/**
* Background-task list plugin, node half. Pure UI plugin: the empty apply
* exists so the plugin appears in the host cordis.yml / Loader; the browser
* half ships via exports["./client"], discovered through the package.json
* dshClient declaration.
*/
/** Host plugin body — no host-side behavior for this source plugin. */
export function apply(): void {}

View File

@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-task`.
* @module @deepseek-ai/dsh-client-ui-task/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-task'
/** Cordis companion plugin name. */
export const name = 'client-ui-task-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this package is a read-only projection of the
* `tasksBySession` mirror onto one header slot entry. It emits no cordis
* events, owns no cross-plugin mutable state, and its single slot registration
* proves disposal through the HMR-safety spec.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */