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

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

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

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

View File

@@ -2,5 +2,5 @@
# 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/README.md
README.md: b6fa426fbe541e2b22d2bf5f19d4397361cf0899
README.zh.md: 5a55bb8c2c31b5215fc73e75e1c4f3aca79add64
README.md: 347a42e0671ad0ae1da6c5488f94f387e85fcf99
README.zh.md: 596ce3910430a183a9750c94b83cb79f43ce037f

View File

@@ -29,6 +29,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
| [`ui-slash/`](ui-slash/README.md) | Coordinates inline command and reference suggestions. |
| [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions. |
| [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references. |
| [`ui-task/`](ui-task/README.md) | Lists this session's background tasks in the conversation header. |
| [`ui-model/`](ui-model/README.md) | Provides model selection in conversation surfaces. |
| [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. |
| [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. |

View File

@@ -29,6 +29,7 @@ dsh web GUI 的浏览器侧shell 启动、浏览器与宿主通信、共享 U
| [`ui-slash/`](ui-slash/README.md) | 协调内联命令和引用建议。 |
| [`ui-skill/`](ui-skill/README.md) | 向内联建议添加 skill技能引用。 |
| [`ui-subagent/`](ui-subagent/README.md) | 提供 subagent 导航、子会话记录状态和内联引用。 |
| [`ui-task/`](ui-task/README.md) | 在会话标题栏列出当前会话的后台任务。 |
| [`ui-model/`](ui-model/README.md) | 在会话界面中提供模型选择。 |
| [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 |
| [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 |

View File

@@ -17,6 +17,7 @@ export type {
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
TaskView,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type {

View File

@@ -22,6 +22,7 @@ export type {
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
MessageId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
TaskView,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,

View File

@@ -16,7 +16,7 @@ const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
/** Empty global standard-kit hooks (the row reads neither). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {

View File

@@ -2,5 +2,5 @@
# 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/runtime/README.md
README.md: a9b604974595b1b7856f74b72d36093491ec1bd1
README.zh.md: 6eee14bdbe7a9fb86b0a12355e7d0a45cd500774
README.md: 5abe72767aa76d5690b3f287391bc8924bc62d03
README.zh.md: 78024fa095a5bcddc50ee121eec64e887f241ba7

View File

@@ -24,6 +24,8 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives.
`SessionListState.tasksBySession` mirrors the Host's `session/tasks` frames last-wins, keyed by session and needing no Session instance. An emptied set is stored as an absent key, so absence and `[]` are one representation and consumers never test a sentinel. Two clears keep it from outliving its truth: `session/subscribed` drops the session's mirror, because a fresh generation sends a baseline only for a non-empty set and a retained list would survive as a phantom, and `host/session-removed` drops it again, because owner disposal removed the records on the mux stream while the removal frame rides the host stream, leaving the two with no relative order.
`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it.
## New Session and the blank mirror

View File

@@ -24,6 +24,8 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。
`SessionListState.tasksBySession` 按 last-wins 镜像宿主的 `session/tasks` 帧,以会话为键,不需要 Session 实例。被清空的集合存为缺失的键,因此「缺失」与 `[]` 是同一种表示,消费方永远不必检测哨兵值。两处清理让它不至于比它所反映的真相活得更久:`session/subscribed` 丢弃该会话的镜像,因为新一代只为非空集合发送 baseline被留下的列表会变成幽灵`host/session-removed` 再丢一次,因为 owner 销毁是在 mux 流上移除记录的,而移除帧走 host 流,两者没有相对顺序。
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit``SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。
## New Session 与 blank 镜像

View File

@@ -36,7 +36,7 @@ export type {
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
} from './sessions/service.ts'
export type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './sessions/manager.ts'
export type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
export type { SubagentAddress, TaskView } from '@deepseek-ai/dsh-client-connection/client'
export type { WorkspaceListPhase } from './workspaces/manager.ts'
export type { WorkspaceListState } from './workspaces/service.ts'
export type {

View File

@@ -4,7 +4,7 @@
import type {
IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
SessionSummary, SubagentAddress, SubagentCatalog, WorkspaceId,
SessionSummary, SubagentAddress, SubagentCatalog, TaskView, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
@@ -47,6 +47,8 @@ export interface SessionListSnapshot {
phase: SessionListPhase
error: RpcError | null
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
/** Background tasks per session; an absent key is an empty set. */
tasksBySession: Readonly<Record<SessionId, readonly TaskView[]>>
currentAddress: SubagentAddress | undefined
}
@@ -137,6 +139,11 @@ export class SessionManager {
private readonly catalogStale = new Set<SessionId>()
private readonly openCatalogs = new Set<SessionId>()
private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
/**
* Background tasks per session, last-wins from `session/tasks`. An empty set
* is stored as an absent key, so absence and `[]` are one representation.
*/
private readonly tasksBySession = new Map<SessionId, readonly TaskView[]>()
private selected: SessionId | undefined
@@ -657,10 +664,23 @@ export class SessionManager {
this.notifier.markDirty()
return
}
if (frame.type === 'session/tasks') {
// Whole-set snapshot, so last-wins with no reconciliation. The Host omits
// the baseline for an empty set, which is the same fact an emptying change
// reports as `[]` — both land as an absent key.
if (frame.tasks.length === 0) this.tasksBySession.delete(frame.sessionId)
else this.tasksBySession.set(frame.sessionId, frame.tasks)
this.notifier.markDirty()
return
}
if (frame.type === 'session/subscribed') {
// Rows past the host's durable baseline rode state a restart lost; drop
// them so last-wins cannot pin a phantom value over recomputed truth.
this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq)
// Same re-baseline reasoning as the queue below: this generation sends a
// task baseline only when the set is non-empty, so a mirror kept from the
// previous generation would survive as a phantom list.
this.tasksBySession.delete(frame.sessionId)
this.notifier.markDirty()
// New mux-generation baseline: discard the previous queue snapshot.
// The host omits session/queue when the live queue is empty, so retaining
@@ -770,6 +790,11 @@ export class SessionManager {
}
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.pendingInteractions.delete(frame.sessionId) // a removed session cannot wait on anyone
// Owner disposal already dropped these registry-side, but that lands on
// the mux stream while this frame rides the host stream, so the two have
// no relative order. Clearing here makes a detached Activation's rows
// disappear whichever arrives first.
this.tasksBySession.delete(frame.sessionId)
if (!durableSubagent) this.projectionStores.delete(frame.sessionId)
// A pull already in flight was requested before this removal and can
// carry the pre-removal parentAvailable:true, which would resurrect
@@ -1006,6 +1031,7 @@ export class SessionManager {
phase: this.listPhase,
error: this.listError,
subagentsByParent: Object.fromEntries(this.catalogs),
tasksBySession: Object.fromEntries(this.tasksBySession),
currentAddress: current === undefined ? undefined : this.addresses.get(current),
}
}

View File

@@ -17,7 +17,7 @@
*/
import type { Context, Fiber } from 'cordis'
import type {
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, WorkspaceId,
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, TaskView, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
@@ -80,6 +80,12 @@ export interface SessionListState {
phase: SessionListPhase
/** Direct durable catalogs keyed by their selected parent address. */
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
/**
* Background tasks each session can see, mirrored last-wins from
* `session/tasks`. A missing key is an empty set — the Host sends no baseline
* for a session without tasks — so consumers read absence, never a sentinel.
*/
tasksBySession: Readonly<Record<SessionId, readonly TaskView[]>>
/** Current session's catalog-derived address, absent on ordinary navigation. */
currentAddress: SubagentAddress | undefined
}
@@ -271,7 +277,7 @@ export class SessionsService implements ISessions {
this.manager = new SessionManager(api, restored.sessionId, restored.subagentAddress)
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'pending',
subagentsByParent: {}, currentAddress: undefined,
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
})
// The manager owns wire truth; the store is its projection. Manager
// notifications are already microtask-batched.
@@ -606,7 +612,7 @@ export class SessionsService implements ISessions {
/** Project the manager's list snapshot into the store (title derivation is display-only). */
private projectList(): void {
const {
items, current, phase, subagentsByParent, currentAddress,
items, current, phase, subagentsByParent, tasksBySession, currentAddress,
} = this.manager.getListSnapshot()
const ids: SessionId[] = []
const byId: Record<SessionId, SessionSummary> = {}
@@ -675,7 +681,7 @@ export class SessionsService implements ISessions {
...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
})
}
this.list.set({ ids, byId, current, phase, subagentsByParent, currentAddress })
this.list.set({ ids, byId, current, phase, subagentsByParent, tasksBySession, currentAddress })
this.pruneScopes()
}

View File

@@ -1110,3 +1110,60 @@ describe('completed reminder', () => {
expect(entry(manager, S2)?.completed).toBe(true)
})
})
describe('background-task mirror', () => {
const view = (over: Partial<{ id: string; status: string; label: string }> = {}) => ({
id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, ...over,
})
const tasksFrame = (sessionId: SessionId, tasks: unknown[]) =>
({ rpcId: 't' as never, payload: { type: 'session/tasks', sessionId, tasks } as never })
it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
manager.handleMuxEnvelope(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })]))
const first = manager.getListSnapshot().tasksBySession
expect(first[S1]).toEqual([view()])
expect(first[S2]?.[0]?.label).toBe('other')
// Last-wins: the newer whole set replaces, it does not merge.
manager.handleMuxEnvelope(tasksFrame(S1, [view({ status: 'completed' })]))
expect(manager.getListSnapshot().tasksBySession[S1]).toEqual([view({ status: 'completed' })])
})
it('stores an emptied set as an absent key so absence and [] read alike', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(true)
manager.handleMuxEnvelope(tasksFrame(S1, []))
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
})
it('clears the mirror on re-subscribe, because a task-free generation sends no baseline', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
manager.handleMuxEnvelope({
rpcId: 's' as never,
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 3 },
})
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
})
it('drops the rows when the session is removed, whichever stream lands first', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'a' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
manager.handleHostEnvelope({ rpcId: 'r' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
})
it('notifies list subscribers so an open header re-renders without a poll', async () => {
const manager = new SessionManager(new FakeApiClient())
const seen = vi.fn()
manager.subscribe(seen)
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
// The notifier batches on a microtask; the frame itself is already applied.
await Promise.resolve()
expect(seen).toHaveBeenCalled()
})
})

View File

@@ -192,7 +192,7 @@ export class TestSessions implements ISessions {
constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) {
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
})
this.channel = new SessionProvideChannel({
rebuildBundles: () => {

View File

@@ -110,7 +110,7 @@ const compaction = (over: Partial<CompactionSummaryNode> = {}): CompactionSummar
/** Empty sessions-list hook for the global standard-kit seat. */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined })
return bindSnapshotSelector(store)
}

View File

@@ -16,7 +16,7 @@ afterEach(() => {
function emptySessions() {
return bindSnapshotSelector(createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
}))
}

View File

@@ -100,7 +100,7 @@ describe('render branch tails', () => {
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget)
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined })
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
@@ -149,7 +149,7 @@ describe('render branch tails', () => {
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget)
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined })
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,

View File

@@ -109,7 +109,7 @@ function bench(over?: BenchOptions) {
useSession: bindSnapshotSelector(session),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
})),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,

View File

@@ -37,7 +37,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
useSession: bindSnapshotSelector(session),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
})),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,

View File

@@ -123,7 +123,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
useSession: bindSnapshotSelector(sessionStore),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
})),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,

View File

@@ -107,7 +107,7 @@ function mount(
ids: listed ? [root, SID] : [root],
byId: { [root]: rootRow, ...listed && { [SID]: childRow } },
current: SID,
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>(workspaceState(workspaceRows))
const session = createSnapshotStore<ConversationSnapshot>(snapshot)

View File

@@ -61,6 +61,7 @@ function props(
},
current: PARENT, phase: 'ready',
subagentsByParent: value === undefined ? nested : { [PARENT]: value, ...nested },
tasksBySession: {},
currentAddress: undefined,
} satisfies SessionListState
function useSessions<T>(select: (snapshot: SessionListState) => T): T {

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

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.

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` 仍会把它报告给模型。

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"
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()
})
})

View File

@@ -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'])
})
})

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"
}
]
}

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'])

View File

@@ -22,7 +22,7 @@ const COPY: Record<string, string> = {
/** Empty global standard-kit hooks (the row reads neither). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {

View File

@@ -101,7 +101,7 @@ async function bench(snapshot: ConversationSnapshot) {
ids: [SID],
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
current: SID,
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
})
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }

View File

@@ -30,7 +30,7 @@ function listStore() {
},
current: undefined,
phase: 'ready',
subagentsByParent: {},
subagentsByParent: {}, tasksBySession: {},
currentAddress: undefined,
})
}

View File

@@ -157,7 +157,7 @@ describe('FileMutationRow diff card', () => {
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
phase: 'ready',
subagentsByParent: {},
subagentsByParent: {}, tasksBySession: {},
currentAddress: undefined,
})
@@ -309,13 +309,13 @@ describe('DetailsPanel diff Output section', () => {
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
subagentsByParent: {},
subagentsByParent: {}, tasksBySession: {},
currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>({

View File

@@ -171,7 +171,7 @@ describe('ReadRow keyed toolview', () => {
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
phase: 'ready',
subagentsByParent: {},
subagentsByParent: {}, tasksBySession: {},
currentAddress: undefined,
})
@@ -255,13 +255,13 @@ describe('DetailsPanel Output section (read)', () => {
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
subagentsByParent: {},
subagentsByParent: {}, tasksBySession: {},
currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>({

View File

@@ -378,7 +378,7 @@ describe('DetailsPanel Output section (search)', () => {
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,

View File

@@ -346,7 +346,7 @@ describe('BashRow terminal card', () => {
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
current: undefined,
phase: 'ready',
subagentsByParent: {},
subagentsByParent: {}, tasksBySession: {},
currentAddress: undefined,
})
@@ -446,13 +446,13 @@ describe('DetailsPanel Output section', () => {
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
subagentsByParent: {},
subagentsByParent: {}, tasksBySession: {},
currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>({
@@ -643,7 +643,7 @@ describe('DetailsPanel Output section', () => {
useSessions={bindSnapshotSelector(createSnapshotStore<SessionListState>(
{
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
}))}
useWorkspaces={bindSnapshotSelector(createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,

View File

@@ -208,7 +208,7 @@ describe('DetailsPanel web Output section', () => {
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,

View File

@@ -122,7 +122,7 @@ function fakeSession(nodes: ConversationSnapshot['nodes']) {
/** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined })
return bindSnapshotSelector(store)
}

View File

@@ -17,7 +17,7 @@ const list = (...items: SessionSummary[]): SessionListState => ({
ids: items.map(item => item.id),
byId: Object.fromEntries(items.map(item => [item.id, item])),
current: undefined,
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
})
const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
workspaceId: wid(id), path: `/projects/${id}`, title,

View File

@@ -29,7 +29,7 @@ const sessionState = (items: readonly SessionSummary[], overrides: Partial<Sessi
byId: Object.fromEntries(items.map(item => [item.id, item])),
current: undefined,
phase: 'ready',
subagentsByParent: {},
subagentsByParent: {}, tasksBySession: {},
currentAddress: undefined,
...overrides,
})

View File

@@ -28,7 +28,7 @@ function hook<T>(snapshot: T) {
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
}
const sessions: SessionListState = {
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
}
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true,