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 files changed
+2130 -68

No files matched your search

+51 -1
View File
@@ -30,7 +30,7 @@ import type {
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
ModelCatalogFailure, ModelProviderGroup,
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView,
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, TaskView, ToolEventView,
WorkspaceId, WorkspaceView,
} from './api/index.ts'
import {
@@ -40,6 +40,9 @@ import {
} from './api/session-search.ts'
// Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
import type {} from '@deepseek-ai/dsh-session-projection'
// Type-only: resolves `ctx.get('tasks')` to the background task registry.
import type {} from '@deepseek-ai/dsh-tasks'
import type { TaskSnapshot } from '@deepseek-ai/dsh-tasks'
// Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column).
import type {} from '@deepseek-ai/dsh-session-projection-cache'
// GoalError narrows domain rejections to their stable codes at the wire boundary.
@@ -261,6 +264,22 @@ function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Sess
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
}
/**
* Project registry snapshots onto the wire view, dropping the three internal
* fields {@link TaskView} documents as absent.
*/
function taskViews(snapshots: readonly TaskSnapshot[]): TaskView[] {
return snapshots.map(task => ({
id: task.id,
kind: task.kind,
label: task.label,
status: task.status,
...task.detail === undefined ? {} : { detail: task.detail },
startedAt: task.startedAt,
...task.finishedAt === undefined ? {} : { finishedAt: task.finishedAt },
}))
}
/**
* Whether the session's conversation has started: no turn has run yet (a
* turn is one model-loop execution). Standalone plugin events — command
@@ -2586,6 +2605,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
queue.push(frame({ type: 'session/queue', sessionId: session.id, items: queueItems(agent) }))
}
}
// Background-task baseline. `ctx.agents.get` is the non-resuming read:
// a session with no live Agent owns no tasks, so it correctly sees only
// the unowned ones, and listing never revives a cold session. An empty
// set sends nothing — absence is how the client reads "no tasks".
const tasks = ctx.get('tasks')
if (tasks !== undefined) {
for (const session of ctx.sessions.list()) {
const views = taskViews(tasks.list(ctx.agents.get(session.id)))
if (views.length > 0) {
queue.push(frame({ type: 'session/tasks', sessionId: session.id, tasks: views }))
}
}
}
// Per-session open-call table for result-view pairing. Bounded by the
// per-turn call count: entries clear on turn/end; a table miss (stream
// opened mid-turn) backscans the session's in-memory events instead.
@@ -2614,6 +2646,24 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
ctx.on('session/disposed', (session: Session) => {
openCalls.delete(session.id)
}),
...tasks === undefined ? [] : [tasks.onTasksChanged((owner) => {
if (owner !== undefined) {
// The exact owner instance the fence compares against, so the
// push stays correct even while that Agent's scope is tearing
// down and a lookup by id would already miss.
queue.push(frame({ type: 'session/tasks', sessionId: owner.id, tasks: taskViews(tasks.list(owner)) }))
return
}
// An unowned task is visible to every caller, so every subscribed
// session's set changed with it.
for (const session of ctx.sessions.list()) {
queue.push(frame({
type: 'session/tasks',
sessionId: session.id,
tasks: taskViews(tasks.list(ctx.agents.get(session.id))),
}))
}
})],
]
return queue.iterate(signal, () => {
muxQueues.delete(queue)