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