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
@@ -13,6 +13,7 @@ import { approvalRequestIdSchema } from './approvals.schema.ts'
import {
contentBlockSchema, messageIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
} from './sessions.schema.ts'
import { taskViewSchema } from './tasks.schema.ts'
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
/** Question shape validated strictly against core dsh-user-interaction. */
@@ -58,6 +59,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
message: messageSchema,
})),
}),
z.object({ type: z.literal('session/tasks'), sessionId: sessionIdSchema, tasks: z.array(taskViewSchema) }),
// value stays wide: it already passed its unit's own schema on the host,
// and deep-validating here would import every domain's schema into the carrier.
z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }),
+15
View File
@@ -14,6 +14,7 @@ import type { CallId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
import type { RpcError, RpcId, RpcRequest } from './rpc.ts'
import type { TaskView } from './tasks.ts'
import type { WorkspaceView } from './workspace.ts'
// Client-side consumers take the render-intent vocabulary from the contract;
@@ -81,6 +82,20 @@ export type MuxFrame =
* in QueueDock, while pending steering renders at the conversation tail.
*/
| { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] }
/**
* Complete set of background tasks this session can see, after every registry
* commit that changes it: registration, the stopping transition, settlement,
* and owner-disposal removal. The registry is process-local and holds no
* durable event, so — exactly like `session/queue` — the whole snapshot is
* what makes a start, a kill, a reconnect, and a second tab converge on one
* authoritative value.
*
* Sent as a subscription baseline only for a session that currently has
* tasks; an absent key means an empty set. A change that empties the set
* still sends `[]`, since that transition is the only one absence cannot
* express.
*/
| { type: 'session/tasks'; sessionId: SessionId; tasks: TaskView[] }
/**
* One projection unit's finished value changed (session-projection RFC).
* Live push state, never logged — replay recomputes on the host (the
+1
View File
@@ -44,6 +44,7 @@ export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
export type {
SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, SubagentsApi,
} from './subagents.ts'
export type { TaskView } from './tasks.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'
@@ -0,0 +1,33 @@
/**
* tasks domain zod schemas: the branded task id and the wire view carried by
* `session/tasks` frames.
*/
import { z } from 'zod'
import type { TaskId } from '@deepseek-ai/dsh-tasks/brand'
import type { TaskView } from './tasks.ts'
import type { Wire } from './rpc.schema.ts'
/** TaskId: one brand cast after non-empty string validation. */
export const taskIdSchema = z.string().min(1) as unknown as z.ZodType<TaskId>
/**
* One wire task view. `kind` stays an open string because producer plugins
* extend the registry's kind map by declaration merging, so the closed set is
* not knowable at this boundary.
*/
export const taskViewSchema = z.object({
id: taskIdSchema,
kind: z.string().min(1),
label: z.string().min(1),
status: z.union([
z.literal('running'),
z.literal('stopping'),
z.literal('completed'),
z.literal('killed'),
z.literal('failed'),
]),
detail: z.string().optional(),
startedAt: z.number().int().nonnegative(),
finishedAt: z.number().int().nonnegative().optional(),
}) satisfies z.ZodType<Wire<TaskView>>
+36
View File
@@ -0,0 +1,36 @@
/**
* Browser-safe background-task domain contract. The registry's live records
* never cross the wire; a view is the subset a human list needs, minted fresh
* per push.
*/
import type { TaskId } from '@deepseek-ai/dsh-tasks/brand'
/**
* One background task as the client sees it.
*
* Three registry fields are deliberately absent. `ownerSession` is redundant
* beside the frame's own `sessionId`; `reported` is an internal notice-delivery
* bit with no user meaning; `outputLimitBytes` is producer-owned model
* presentation policy that never reaches a human surface.
*/
export interface TaskView {
/** Registry-issued `<kind>-N` identity, stable for the task's whole life. */
id: TaskId
/**
* Producer kind (`bash`, `pwsh`, `pty-send`, `subagent`, …). Kept as a bare
* string because producer plugins extend the kind map by declaration merging,
* so no client build can enumerate the closed set.
*/
kind: string
/** Producer-supplied one-line label: the command, or the delegation description. */
label: string
/** Current lifecycle state. */
status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
/** Kind-specific status detail ('exit code: 3'), present once the producer supplied one. */
detail?: string
/** Epoch ms when the task was registered. */
startedAt: number
/** Epoch ms when the task settled; absent while live. */
finishedAt?: number
}