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:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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` 仍会把它报告给模型。
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
@@ -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 {}
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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'])
|
||||
Reference in New Issue
Block a user