mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
116
packages/client/ui-task/src/client/TaskListAction.module.css
Normal file
116
packages/client/ui-task/src/client/TaskListAction.module.css
Normal 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;
|
||||
}
|
||||
185
packages/client/ui-task/src/client/TaskListAction.tsx
Normal file
185
packages/client/ui-task/src/client/TaskListAction.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
packages/client/ui-task/src/client/index.ts
Normal file
40
packages/client/ui-task/src/client/index.ts
Normal 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),
|
||||
)
|
||||
}
|
||||
45
packages/client/ui-task/src/client/locales.ts
Normal file
45
packages/client/ui-task/src/client/locales.ts
Normal 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
|
||||
6
packages/client/ui-task/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-task/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
9
packages/client/ui-task/src/index.ts
Normal file
9
packages/client/ui-task/src/index.ts
Normal 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
packages/client/ui-task/src/invariant.ts
Normal file
32
packages/client/ui-task/src/invariant.ts
Normal 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 */
|
||||
Reference in New Issue
Block a user