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
+6
View File
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-task/README.md
README.md: 318984808838cb7a336a4c34c95a2f27db578423
README.zh.md: 7adeb254662fd23cf4bd1efc8618471cf74df7c0
+24
View File
@@ -0,0 +1,24 @@
# @deepseek-ai/dsh-client-ui-task
English | [中文](README.zh.md)
Web background-task feature owner: contributes one entry to `conversation.session.header.actions` listing the `ctx.tasks` records this session can see. The data arrives entirely through the `tasksBySession` list mirror that [`dsh-client-runtime`](../runtime/README.md) folds from `session/tasks` frames, so this package issues no RPC and holds no state beyond popover visibility.
The trigger renders only when the session has at least one task, so an ordinary conversation never grows a control for a capability it is not using. Its badge counts `running` plus `stopping` and is omitted at zero, leaving a session that holds only finished tasks a quiet entry point into its history rather than one advertising a count of nothing. The popover is a flat list: live rows first by `startedAt` ascending, then settled rows by `finishedAt` descending, with a same-millisecond tie broken on start order so the host's map iteration never decides it. A row shows the producer kind, the label, a status marker, the producer's `detail` in place of the generic status word once it has one, and an elapsed duration. That duration advances once per second while the row is live and freezes at `finishedAt`; the clock runs only while an open list holds something that moves. A settled row missing `finishedAt` reads as zero rather than as a negative figure, and a duration past an hour stays in hours rather than growing a day vocabulary no producer currently reaches.
Settled rows stay visible and de-emphasized until the registry drops them at owner disposal. They are in the snapshot, a failed task's `detail` is the only place its failure is legible, and filtering them out here is work the output and cancellation phases would undo. A running one-shot background subagent therefore appears both here and in the [subagent catalog](../ui-subagent/README.md): the catalog navigates into the child's transcript, while this list is the only handle a future cancellation can attach to.
Escape closes the list and returns focus to the trigger, as does a pointer press outside it. The last task disappearing closes the list before the control unmounts, so focus never vanishes from a removed node. Styling uses tokens only; copy goes through the package's own `task` locale namespace. The behavior is specified by the [Web background-task display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md).
## Model Experience
None, as this package renders host-computed registry state for a human and touches no prompt, message, schema, stream, or tool result. The model's own view of the same tasks stays with [`dsh-tool-tasks`](../../tasks/tool-tasks/README.md).
#### KV Cache effect
None; the package never assembles or sends provider requests.
## Known Limitations and Deferred Work
- **Rows are read-only** — a task's streamed output and a human-initiated cancellation are separate phases. Cancellation additionally owes a model-facing decision the seam does not answer today: `kill()` marks terminal delivery reported, so an interrupt written against the current contract would leave the model believing its task is still running.
- **The list is not the registry's own set** — it shows what the owning session can see through the wire view, so an unowned task (one started without a live `Agent`) is invisible here while `task_list` still reports it to the model.
+24
View File
@@ -0,0 +1,24 @@
# @deepseek-ai/dsh-client-ui-task
[English](README.md) | 中文
Web 后台任务特性的归属方:向 `conversation.session.header.actions` 贡献一个条目,列出当前会话可见的 `ctx.tasks` 记录。数据完全来自 [`dsh-client-runtime`](../runtime/README.md) 从 `session/tasks` 帧折叠出的 `tasksBySession` 列表镜像,因此本包不发任何 RPC,除弹层开合外不持有任何状态。
只有当会话至少有一个任务时才渲染触发器,普通对话不会因为一项未被使用的能力而长出控件。角标计数为 `running``stopping`,为零时省略,这样只剩已完成任务的会话保留一个安静的历史入口,而不是宣告一个「零」。弹层是一个扁平列表:活跃行在前按 `startedAt` 升序,随后终态行按 `finishedAt` 降序;毫秒相同的并列按启动顺序打破,宿主的 map 迭代顺序永远不参与决定。一行显示生产者 kind、label、状态标记、生产者一旦给出 `detail` 就取代通用状态词的那段文字,以及已耗时。该耗时在活跃时每秒推进,并在 `finishedAt` 冻结;只有当打开的列表里确实有会动的东西时时钟才运行。缺少 `finishedAt` 的终态行读作零而不是负数,超过一小时的耗时停留在小时单位,不会长出任何生产者目前都到不了的「天」词汇。
终态行保持可见并弱化,直到注册表在 owner 销毁时把它们丢掉。它们本就在快照里,失败任务的 `detail` 是其失败唯一可读之处,在这里过滤掉它们是输出与中断两期要推翻的工作。因此一个运行中的一次性后台 subagent 会同时出现在这里和 [subagent 目录](../ui-subagent/README.md)里:目录负责进入子会话的 transcript,而这个列表是将来中断能力唯一可能附着的句柄。
Escape 关闭列表并把焦点交还触发器,在其外部按下指针同理。最后一个任务消失时先关闭列表再卸载控件,焦点因此不会从一个被移除的节点上凭空消失。样式只用 token;文案走本包自己的 `task` locale 命名空间。行为由 [Web 后台任务展示 Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md) 规定。
## Model Experience
无,因为本包为人类渲染宿主计算出的注册表状态,不触及 prompt、消息、schema、流或工具结果。模型对同一批任务的视角仍属于 [`dsh-tool-tasks`](../../tasks/tool-tasks/README.md)。
#### KV Cache effect
无;本包从不组装或发送 provider 请求。
## Known Limitations and Deferred Work
- **行是只读的** —— 任务的流式输出与人类发起的中断是各自独立的阶段。中断还额外欠一个 seam 目前没有回答的、面向模型的决策:`kill()` 会把终态投递标为已上报,所以照当前契约写出来的中断会让模型一直以为它的任务还在跑。
- **列表不等于注册表自己的集合** —— 它展示的是拥有它的会话通过线路视图能看到的东西,因此一个无主任务(在没有活体 `Agent` 时启动的任务)在这里不可见,而 `task_list` 仍会把它报告给模型。
+68
View File
@@ -0,0 +1,68 @@
{
"name": "@deepseek-ai/dsh-client-ui-task",
"description": "Session-header background-task list: live registry state mirrored from session/tasks frames",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-primitives"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts"
]
}
@@ -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;
}
@@ -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>
)
}
@@ -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),
)
}
@@ -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
+6
View File
@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'
+9
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 {}
+32
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 */
@@ -0,0 +1,89 @@
/**
* ui-task plugin halves: the browser entry's dictionary and header-slot
* registrations against the real SlotsService (with fiber teardown proving
* removal — HMR safety), the inert node entry, and the invariant companion's
* ownership reservation.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '../src/client/index.ts'
import { apply as applyNode } from '../src/index.ts'
import * as TaskInvariant from '../src/invariant.ts'
import { en, NS, zh } from '../src/client/locales.ts'
/** Slot ledger reader: entry ids currently registered in the header list. */
function headerEntryIds(ctx: Context): (string | undefined)[] {
return ctx.slots
.entries('conversation.session.header.actions')
.map(entry => entry.options.id)
}
/** Boot the browser half over a real slot tree that declares the header list. */
async function bench(): Promise<{ ctx: Context; fiber: ReturnType<Context['plugin']> }> {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
ctx.slots.register({
name: 'root',
children: {
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
},
} as never, () => null)
ctx.provide('sessions', {})
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, fiber }
}
describe('ui-task browser half', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['sessions', 'slots', 'locale'])
})
it('registers the header action, and fiber teardown removes it (HMR safety)', async () => {
const { ctx, fiber } = await bench()
expect(headerEntryIds(ctx)).toContain('task-list')
await fiber.dispose()
expect(headerEntryIds(ctx)).not.toContain('task-list')
})
it('registers both dictionaries under its own namespace and releases them with the fiber', async () => {
const { ctx, fiber } = await bench()
const translate = ctx.locale.bind(NS)
expect(translate('list.aria')).toBe(zh['list.aria'])
ctx.locale.setLocale('en')
expect(translate('list.aria')).toBe(en['list.aria'])
// Withdrawn dictionaries leave the key unresolved rather than translated.
await fiber.dispose()
expect(translate('list.aria')).not.toBe(en['list.aria'])
})
it('keeps the English dictionary key-identical to the Chinese source of truth', () => {
expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort())
})
})
describe('ui-task node half', () => {
it('contributes no host behavior', () => {
// The node half exists only so the plugin appears in the Loader tree.
expect(applyNode).not.toThrow()
})
})
describe('ui-task invariant companion', () => {
it('reserves package ownership under its declared companion name', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
const fiber = ctx.plugin(TaskInvariant)
await fiber.await()
expect(TaskInvariant.name).toBe('client-ui-task-invariant')
expect(TaskInvariant.inject).toEqual(['invariants'])
// Emitting an unrelated event proves the companion installed no audit.
expect(() => { (ctx.emit as (event: string) => void)('slots/changed') }).not.toThrow()
await fiber.dispose()
})
})
@@ -0,0 +1,239 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionId, SessionListState, TaskView } from '@deepseek-ai/dsh-client-runtime/client'
import { TaskListAction, type TaskListActionProps } from '../src/client/TaskListAction.tsx'
import { zh } from '../src/client/locales.ts'
// Live rows render `now - startedAt`, so every assertion needs a pinned clock.
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(START)
})
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})
const SESSION = 'session' as SessionId
const START = 1_700_000_000_000
const t: TaskListActionProps['t'] = makeTranslate(zh)
function task(over: Partial<TaskView> = {}): TaskView {
return {
id: 'bash-1' as TaskView['id'],
kind: 'bash',
label: 'pnpm run build',
status: 'running',
startedAt: START,
...over,
}
}
function props(tasks: readonly TaskView[] | undefined): TaskListActionProps {
const state = {
ids: [SESSION],
byId: {},
current: SESSION,
phase: 'ready',
subagentsByParent: {},
tasksBySession: tasks === undefined ? {} : { [SESSION]: tasks },
currentAddress: undefined,
} satisfies SessionListState
function useSessions<T>(select: (snapshot: SessionListState) => T): T {
return select(state)
}
return { sessionId: SESSION, useSessions, t } as unknown as TaskListActionProps
}
/**
* Rows in render order as `[kind, label, status, duration]`. Adjacent spans
* carry no whitespace between them, so the cells are read one element at a
* time rather than split out of a flattened string.
*/
function rowCells(): string[][] {
return within(screen.getByRole('list', { name: zh['list.aria'] }))
.getAllByRole('listitem')
.map(row => [...row.children]
.map(cell => cell.textContent ?? '')
.filter(text => text !== ''))
}
describe('TaskListAction visibility', () => {
it('renders nothing while the session has no tasks', () => {
const { container } = render(<TaskListAction {...props(undefined)} />)
expect(container.innerHTML).toBe('')
})
it('counts only live tasks, and falls back to the total when none are live', () => {
const { rerender } = render(<TaskListAction {...props([task(), task({ id: 'bash-2' as TaskView['id'] })])} />)
expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined()
rerender(<TaskListAction {...props([task({ status: 'completed', finishedAt: START + 3_000 })])} />)
expect(screen.getByRole('button', { name: '1 个后台任务' })).toBeDefined()
})
it('closes and unmounts when the last task disappears while the list is open', () => {
const { container, rerender } = render(<TaskListAction {...props([task()])} />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined()
rerender(<TaskListAction {...props([])} />)
expect(container.innerHTML).toBe('')
})
})
describe('TaskListAction rows', () => {
it('orders live tasks by start, then settled tasks newest-first', () => {
render(<TaskListAction {...props([
task({ id: 'bash-3' as TaskView['id'], label: 'old done', status: 'completed', startedAt: START, finishedAt: START + 1_000 }),
task({ id: 'bash-4' as TaskView['id'], label: 'new done', status: 'failed', startedAt: START, finishedAt: START + 9_000 }),
task({ id: 'bash-2' as TaskView['id'], label: 'later live', startedAt: START + 5_000 }),
task({ id: 'bash-1' as TaskView['id'], label: 'earlier live', startedAt: START }),
])} />)
fireEvent.click(screen.getByRole('button'))
expect(rowCells()).toEqual([
['bash', 'earlier live', '运行中', '0秒'],
['bash', 'later live', '运行中', '0秒'],
['bash', 'new done', '已失败', '9秒'],
['bash', 'old done', '已完成', '1秒'],
])
})
it('breaks a settled tie on start order so map iteration never decides it', () => {
render(<TaskListAction {...props([
task({ id: 'bash-2' as TaskView['id'], label: 'second', status: 'completed', startedAt: START + 10, finishedAt: START + 100 }),
task({ id: 'bash-1' as TaskView['id'], label: 'first', status: 'completed', startedAt: START, finishedAt: START + 100 }),
])} />)
fireEvent.click(screen.getByRole('button'))
expect(rowCells().map(cells => cells[1])).toEqual(['first', 'second'])
})
it('prefers the producer detail over the generic status word', () => {
render(<TaskListAction {...props([
task({ status: 'killed', detail: 'signal: SIGTERM', finishedAt: START + 2_000 }),
])} />)
fireEvent.click(screen.getByRole('button'))
expect(rowCells()[0]).toContain('signal: SIGTERM')
})
it('renders every status word, including the stopping transition', () => {
render(<TaskListAction {...props([
task({ id: 'bash-1' as TaskView['id'], label: 'a', status: 'running' }),
task({ id: 'bash-2' as TaskView['id'], label: 'b', status: 'stopping' }),
task({ id: 'bash-3' as TaskView['id'], label: 'c', status: 'completed', finishedAt: START }),
task({ id: 'bash-4' as TaskView['id'], label: 'd', status: 'killed', finishedAt: START }),
task({ id: 'bash-5' as TaskView['id'], label: 'e', status: 'failed', finishedAt: START }),
])} />)
fireEvent.click(screen.getByRole('button'))
const words = rowCells().map(cells => cells[2])
expect(new Set(words)).toEqual(new Set(['运行中', '正在停止', '已完成', '已取消', '已失败']))
})
})
describe('TaskListAction duration', () => {
it('advances a live row once per second and freezes a settled one', () => {
vi.setSystemTime(START + 1_000)
render(<TaskListAction {...props([
task({ id: 'bash-1' as TaskView['id'], label: 'live' }),
task({ id: 'bash-2' as TaskView['id'], label: 'done', status: 'completed', finishedAt: START + 4_000 }),
])} />)
fireEvent.click(screen.getByRole('button'))
expect(rowCells()[0]).toContain('1秒')
expect(rowCells()[1]).toContain('4秒')
act(() => { vi.advanceTimersByTime(2_000) })
expect(rowCells()[0]).toContain('3秒')
expect(rowCells()[1]).toContain('4秒')
})
it('widens to minutes and then hours, and never shows a negative figure', () => {
render(<TaskListAction {...props([
task({ id: 'bash-1' as TaskView['id'], label: 'm', status: 'completed', finishedAt: START + 125_000 }),
task({ id: 'bash-2' as TaskView['id'], label: 'h', status: 'completed', finishedAt: START + 7_380_000 }),
// A clock that moved backwards must not render a negative duration.
task({ id: 'bash-3' as TaskView['id'], label: 'skew', status: 'completed', startedAt: START + 5_000, finishedAt: START }),
])} />)
fireEvent.click(screen.getByRole('button'))
expect(rowCells().map(cells => cells[3])).toEqual(['2小时3分', '2分5秒', '0秒'])
})
it('runs no clock while the list is closed', () => {
const interval = vi.spyOn(globalThis, 'setInterval')
render(<TaskListAction {...props([task()])} />)
expect(interval).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button'))
expect(interval).toHaveBeenCalledTimes(1)
})
it('runs no clock for an open list holding only settled tasks', () => {
const interval = vi.spyOn(globalThis, 'setInterval')
render(<TaskListAction {...props([task({ status: 'completed', finishedAt: START })])} />)
fireEvent.click(screen.getByRole('button'))
expect(interval).not.toHaveBeenCalled()
})
})
describe('TaskListAction dismissal', () => {
it('closes on Escape and returns focus to the trigger', () => {
render(<TaskListAction {...props([task()])} />)
const trigger = screen.getByRole('button')
fireEvent.click(trigger)
expect(trigger.getAttribute('aria-expanded')).toBe('true')
fireEvent.keyDown(trigger, { key: 'Escape' })
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(document.activeElement).toBe(trigger)
})
it('ignores other keys and a closed-list Escape', () => {
render(<TaskListAction {...props([task()])} />)
const trigger = screen.getByRole('button')
fireEvent.keyDown(trigger, { key: 'Escape' })
expect(trigger.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(trigger)
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
expect(trigger.getAttribute('aria-expanded')).toBe('true')
})
it('closes on an outside pointer press but not on one inside', () => {
render(<TaskListAction {...props([task()])} />)
const trigger = screen.getByRole('button')
fireEvent.click(trigger)
fireEvent.pointerDown(screen.getByRole('list', { name: zh['list.aria'] }))
expect(trigger.getAttribute('aria-expanded')).toBe('true')
fireEvent.pointerDown(document.body)
expect(trigger.getAttribute('aria-expanded')).toBe('false')
})
})
describe('TaskListAction wire tolerance', () => {
it('treats a settled task with no finishedAt as zero-duration and sorts it by start', () => {
// `finishedAt` is optional on the wire; the Host always sets it, so this
// covers a producer or carrier that ever stops doing so.
render(<TaskListAction {...props([
task({ id: 'bash-1' as TaskView['id'], label: 'no finish', status: 'completed' }),
task({ id: 'bash-2' as TaskView['id'], label: 'finished', status: 'completed', startedAt: START - 1_000, finishedAt: START + 2_000 }),
])} />)
fireEvent.click(screen.getByRole('button'))
expect(rowCells().map(cells => [cells[1], cells[3]])).toEqual([
['finished', '3秒'],
['no finish', '0秒'],
])
})
it('falls back to start order when neither settled task carries a finish time', () => {
render(<TaskListAction {...props([
task({ id: 'bash-2' as TaskView['id'], label: 'later', status: 'failed', startedAt: START + 1_000 }),
task({ id: 'bash-1' as TaskView['id'], label: 'earlier', status: 'failed', startedAt: START }),
])} />)
fireEvent.click(screen.getByRole('button'))
expect(rowCells().map(cells => cells[1])).toEqual(['later', 'earlier'])
})
})
+33
View File
@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
{
"path": "../../support/invariants"
}
]
}
+3
View File
@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-task', ['lib/types/index.js', 'lib/types/invariant.js'])