refactor(tasks): 后台任务改读宿主客户端 jobs 服务,删除自建三条路由

- 新增 src/client/jobs-client.ts:结构探测 ctx.jobs(watchRows / observe /
  state / kill),引用计数的按会话 roster 订阅、只观测展开的那一行、
  跨树去重(无主任务归到承载它的被观测会话,树外 owner 丢弃)。
- 抽屉与输出浮窗改读该服务:行来自推送 roster(状态 / 时长 / owner / 进度),
  输出面换 TerminalBlock 显示宿主为观察者流的保留输出(gapBefore → 截断提示、
  error → 失败提示),kill 走 jobs.kill(owner, jobId),两击确认不变。
- 自动激活(use-host-feeds)改骑同一条推送 roster;detectNewJob 增加
  「观测起点时钟」参数,避免「页面打开时已在跑的任务」被当成新工作。
- 删除 src/jobs-routes.ts(+ tests/jobs-routes.spec.ts)、index.ts 的三条路由、
  api.jobsList/jobOutput/jobKill 与 JobOutputResult、context-types 的
  SidebarJobsService(换成 SidebarClientJobsService/SidebarJobsSnapshot/
  SidebarObservedJob 镜像,SidebarJobView 补 owner/progress)、
  页面里的 jobs.list 轮询与 collectTreeJobs。
- 三个 spec 改用假 ctx.jobs 服务(jobs-view 14 例 / 自动激活 12 例 /
  页面 20 例):观测只在展开行、切换与关闭释放观测、watch 隐藏时不建立、
  卸载时全部释放、无服务时不渲染任务面。

验证:typecheck / lint 通过;pnpm test 1240 passed / 9 skipped;pnpm build + pack 通过。

(cherry picked from commit 7d9b82e73190218f05910433ed520cb90e4d4ab2)
This commit is contained in:
Menghuan1918
2026-09-27 22:18:35 +08:00
parent f584b67813
commit 48294d336e
16 changed files with 763 additions and 1050 deletions
+53 -65
View File
@@ -1,24 +1,26 @@
/**
* The background-jobs bottom drawer of the Tasks page and its output popover.
*
* Drawer: the tree's jobs (owner-labeled, fed by the `session/jobs` push
* mirror) collapse into a bottom bar that AUTO-COLLAPSES once the tree has
* many agents — the manual toggle always wins afterwards.
* Drawer: the tree's jobs (owner-labeled, fed by the HOST's client jobs
* service — see jobs-client.ts) collapse into a bottom bar that AUTO-COLLAPSES
* once the tree has many agents — the manual toggle always wins afterwards.
*
* Popover: clicking a row opens a DRAGGABLE, portalled output card (see
* AnchoredPopover) instead of docking a pane inside the page. It shows the
* output the MODEL has read (event replay — never the model's job_output
* cursor), with a copy action, a follow-latest switch, the two-click kill,
* and a terminal-style tail while the job runs.
* output the host streams for an OBSERVER (never the model's consuming
* cursor), with a copy action, a follow-latest switch, the two-click kill, and
* a terminal-style tail while the job runs.
*
* Every control is a host primitive (Button / Tag / Switch / StateDot).
* Every control is a host primitive (Button / Tag / Switch / StateDot /
* TerminalBlock).
*/
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import {
Button, IconChevronUpOutlineRegular, IconCopyOutlineRegular, IconStopFillRegular, StateDot, Switch, Tag,
type TagTone,
TerminalBlock, type TagTone,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarJobView } from '../context-types.ts'
import type { SidebarClientJobsService, SidebarJobView, SidebarObservedJob } from '../context-types.ts'
import { terminalBlockLabels } from './block-labels.ts'
import {
formatJobDuration,
isJobLive,
@@ -26,12 +28,9 @@ import {
jobStatusLabel,
type TreeJob,
} from './subagent-jobs.ts'
import { api, type JobOutputResult } from './api.ts'
import { t } from './locales.ts'
import css from './tasks-graph.module.css'
/** Refresh cadence of an open job-output popover while its job runs. */
const JOB_POLL_MS = 2000
/** How long the kill button stays armed before it needs re-confirming. */
const JOB_KILL_ARM_MS = 3000
/** The agent count at which the drawer starts collapsed. */
@@ -47,6 +46,10 @@ function jobTone(job: SidebarJobView): TagTone {
export interface JobsDrawerProps {
rows: readonly TreeJob[]
/** The host client jobs service; absent → no kill control is offered. */
jobs: SidebarClientJobsService | undefined
/** The observed output of the job whose panel is open (host stream). */
observed: SidebarObservedJob | undefined
/** Agent count of the tree (root included) — the auto-collapse signal. */
agentCount: number
/** Open the output popover of one row (anchor = the row's main button). */
@@ -56,7 +59,7 @@ export interface JobsDrawerProps {
}
export function JobsDrawer(props: JobsDrawerProps): ReactNode {
const { rows, agentCount, onOpenOutput, openJobId } = props
const { rows, agentCount, jobs, onOpenOutput, openJobId } = props
const autoOpen = agentCount < JOBS_DRAWER_COLLAPSE_AT
/** Manual override; undefined = follow the auto rule. */
const [manualOpen, setManualOpen] = useState<boolean | undefined>(undefined)
@@ -90,17 +93,18 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
}, [liveCount])
const kill = useCallback(async (row: TreeJob): Promise<void> => {
if (jobs === undefined) return
setKillingId(row.job.id)
setKillErrorId(undefined)
try {
await api.jobKill({ sessionId: row.ownerSessionId }, row.job.id)
await jobs.kill(row.ownerSessionId, row.job.id)
} catch {
setKillErrorId(row.job.id)
} finally {
setKillingId(undefined)
setArmedId(undefined)
}
}, [])
}, [jobs])
if (rows.length === 0) return null
@@ -205,46 +209,24 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
* keeps its two-click confirm.
*/
export function JobOutputPopoverContent(props: {
ownerSessionId: string
/** The host client jobs service (absent → the panel only reports that). */
jobs: SidebarClientJobsService | undefined
job: SidebarJobView
active: boolean
/** The owner session the roster carried the job under (kill + observation). */
ownerSessionId: string
/** The host's streamed observation of THIS job (undefined until it arrives). */
observed: SidebarObservedJob | undefined
}): ReactNode {
const { ownerSessionId, job, active } = props
const [state, setState] = useState<'loading' | JobOutputResult | 'error'>('loading')
const { jobs, job, ownerSessionId, observed } = props
const [follow, setFollow] = useState(true)
const [copied, setCopied] = useState(false)
const [armed, setArmed] = useState(false)
const [killing, setKilling] = useState(false)
const [killFailed, setKillFailed] = useState(false)
const controllerRef = useRef<AbortController | undefined>(undefined)
const preRef = useRef<HTMLPreElement>(null)
const live = isJobLive(job)
const load = useCallback(async (): Promise<void> => {
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
try {
const result = await api.jobOutput({ sessionId: ownerSessionId }, job.id, controller.signal)
setState(result)
} catch {
// A newer pull aborted this one, or the wire failed: keep the last
// known output; only a popover that never loaded anything shows an error.
setState(current => (current === 'loading' ? 'error' : current))
}
}, [ownerSessionId, job.id])
useEffect(() => {
void load()
if (!active || !live) return
const timer = window.setInterval(() => { void load() }, JOB_POLL_MS)
return () => { window.clearInterval(timer) }
// isJobLive reads only job.status; the job object churns on every catalog
// refresh and must not restart the poll interval.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [load, active, job.status])
useEffect(() => () => { controllerRef.current?.abort() }, [])
const text = observed?.text ?? ''
const labels = useMemo(() => terminalBlockLabels(), [])
// The armed kill disarms itself, like the drawer's button.
useEffect(() => {
@@ -253,26 +235,22 @@ export function JobOutputPopoverContent(props: {
return () => { window.clearTimeout(timer) }
}, [armed])
// Terminal-tail behavior: each refresh pins the view to the newest output
// Terminal-tail behavior: every frame pins the view to the newest output
// while the reader keeps the follow switch on.
useEffect(() => {
if (!live || !follow || typeof state !== 'object' || state.text.length === 0) return
if (!live || !follow || text.length === 0) return
const pre = preRef.current
if (pre !== null) pre.scrollTop = pre.scrollHeight
// Same as the poll effect: only the status transition matters.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state, job.status, follow])
}, [text, live, follow])
const text = typeof state === 'object' ? state.text : ''
/** Two-click kill from the popover (then re-read so the row settles). */
/** Two-click kill from the popover (the roster settles the row afterwards). */
const kill = async (): Promise<void> => {
if (jobs === undefined) return
setKilling(true)
setKillFailed(false)
try {
await api.jobKill({ sessionId: ownerSessionId }, job.id)
await jobs.kill(ownerSessionId, job.id)
setArmed(false)
await load()
} catch {
setKillFailed(true)
} finally {
@@ -311,16 +289,26 @@ export function JobOutputPopoverContent(props: {
{job.detail !== undefined && job.detail !== '' && <span>{job.detail}</span>}
<span className={css.popHint}>{t('jobDragHint')}</span>
</div>
{state === 'loading' && <div className={css.popHint}>{t('loading')}</div>}
{state === 'error' && <div className={css.popError}>{t('jobOutputError')}</div>}
{typeof state === 'object' && (
{observed === undefined && <div className={css.popHint}>{t('loading')}</div>}
{observed !== undefined && (
<>
{state.text.length > 0
? <pre ref={preRef} className={`${css.popPre} ${css.jobPopPre}`} data-popover-no-drag>{state.text}</pre>
: state.read
? <div className={css.popHint}>{t('jobNoOutput')}</div>
: <div className={css.popHint}>{t('jobNotReadYet')}</div>}
{state.truncated && <div className={css.popHint}>{t('jobOutputTruncated')}</div>}
{observed.gapBefore && <div className={css.popHint}>{t('jobOutputTruncated')}</div>}
{observed.error !== undefined && <div className={css.popError}>{t('jobOutputError')}</div>}
{observed.text.length > 0
? (
<div ref={preRef as unknown as React.RefObject<HTMLDivElement>} data-popover-no-drag>
<TerminalBlock
command={job.label}
output={observed.text}
running={observed.streaming}
runStateDot={false}
maxLines={Number.POSITIVE_INFINITY}
copyText={job.label}
labels={labels}
/>
</div>
)
: <div className={css.popHint}>{t('jobNoOutput')}</div>}
</>
)}
<div className={css.jobPopActions} data-popover-no-drag>
+44 -39
View File
@@ -34,8 +34,9 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import type {
Context,
SidebarClientJobsService,
SidebarObservedJob,
SidebarChildLiveView,
SidebarJobView,
SidebarSessionList,
SidebarSubagentAddress,
} from '../context-types.ts'
@@ -46,9 +47,12 @@ import {
} from './subagent-detect.ts'
import { subagentCatalogs } from './subagent-catalog.ts'
import { treeSessionIds } from './subagent-lineage.ts'
import { collectTreeJobs, orderJobs } from './subagent-jobs.ts'
import { orderJobs, type TreeJob } from './subagent-jobs.ts'
import { api, type TeamsViewResult } from './api.ts'
import { usePolling } from './use-polling.ts'
import {
clientJobs, collectRows, useJobObservation, useJobWatchers, useJobsSnapshot,
} from './jobs-client.ts'
import { t } from './locales.ts'
import { buildTasksModel, type TasksAgentNode, type TasksWorkflowNode } from './tasks-model.ts'
import { TasksGraph } from './TasksGraph.tsx'
@@ -66,8 +70,6 @@ import legacy from './SubagentView.module.css'
const POLL_MS = 3000
/** Poll cadence of the workflow-run and team views while the page is visible. */
const TELEMETRY_POLL_MS = 5000
/** Poll cadence of the background-job lists (one read per tree session). */
const JOBS_POLL_MS = 3000
/**
* The workspace face's "show this conversation" verb. DSH moved child and
@@ -156,43 +158,34 @@ function useTeamView(
}
/**
* The background-job lists of the whole tree, keyed by OWNER session.
*
* DSH 0.1.7 dropped the client session snapshot's `jobsBySession` push mirror,
* so the registry is read through the plugin's own `jobs.list` route — and the
* registry's access fence admits a job to its OWNER session only, so the whole
* tree is one read per tree session, fanned out on each tick. A host without
* the jobs service (503) or one dropped read degrades to an empty set for THAT
* session; the next tick retries.
* The tree's background jobs, read through the HOST's client jobs service
* (`ctx.jobs` — see jobs-client.ts). Watching is per tree session (the host
* shares one stream between watchers) and only while the page is on screen;
* a deployment without the service renders no jobs surface at all.
*/
function useTreeJobs(
ctx: Context,
byId: SidebarSessionList['byId'],
rootId: string | undefined,
active: boolean,
): Readonly<Record<string, readonly SidebarJobView[]>> {
): {
rows: TreeJob[]
jobs: SidebarClientJobsService | undefined
/** The observed output per job id (the panel picks its own entry). */
observed: Readonly<Record<string, SidebarObservedJob | undefined>>
} {
const jobs = useMemo(() => clientJobs(ctx), [ctx])
const snapshot = useJobsSnapshot(jobs)
const treeIds = useMemo(
() => (rootId === undefined ? [] : [...treeSessionIds(byId, rootId)]),
[byId, rootId],
)
const [jobsBySession, setJobsBySession] = useState<Readonly<Record<string, readonly SidebarJobView[]>>>({})
useEffect(() => { setJobsBySession({}) }, [rootId])
const poll = useCallback(async (signal: AbortSignal): Promise<void> => {
const entries = await Promise.all(treeIds.map(async (sessionId): Promise<[string, readonly SidebarJobView[]]> => {
try {
const result = await api.jobsList(sessionId, signal)
return [sessionId, result.jobs]
} catch {
return [sessionId, []]
}
}))
if (!signal.aborted) setJobsBySession(Object.fromEntries(entries))
}, [treeIds])
usePolling(active && treeIds.length > 0, poll, {
intervalMs: JOBS_POLL_MS,
mode: 'self-scheduling',
immediate: true,
})
return jobsBySession
useJobWatchers(jobs, treeIds, active)
const rows = useMemo(
() => (rootId === undefined ? [] : orderJobs(collectRows(snapshot, byId, new Set(treeIds)))),
[snapshot, byId, rootId, treeIds],
)
return { rows, jobs, observed: snapshot.observed }
}
/** The open popover of the page (one at a time, anchored). */
@@ -380,12 +373,15 @@ export function SubagentView(props: {
)
const agentCount = totals.count + 1
/** The tree's ordered job rows (owner-labeled, read per tree session). */
const jobsBySession = useTreeJobs(byId, rootId, active)
const jobRows = useMemo(
() => orderJobs(collectTreeJobs(byId, jobsBySession, rootId)),
[byId, jobsBySession, rootId],
)
/** The tree's ordered job rows (owner-labeled, off the host's push roster). */
const treeJobs = useTreeJobs(ctx, byId, rootId, active)
const jobRows = treeJobs.rows
// Only the open panel's job is observed: the host streams output per observer.
const openJob = popover?.kind === 'job'
? jobRows.find(row => row.job.id === popover.jobId)
: undefined
useJobObservation(treeJobs.jobs, openJob?.ownerSessionId, openJob?.job.id)
const observedJob = openJob === undefined ? undefined : treeJobs.observed[openJob.job.id]
/** Catalogs that failed to load (surfaced as one banner in both modes). */
const failedParents = useMemo(
@@ -456,7 +452,14 @@ export function SubagentView(props: {
if (popover.kind === 'job') {
const row = jobRows.find(candidate => candidate.job.id === popover.jobId)
if (row === undefined) return null
return <JobOutputPopoverContent ownerSessionId={row.ownerSessionId} job={row.job} active={active} />
return (
<JobOutputPopoverContent
jobs={treeJobs.jobs}
ownerSessionId={row.ownerSessionId}
job={row.job}
observed={treeJobs.observed[row.job.id]}
/>
)
}
const node = model.find(candidate => candidate.id === popover.nodeId)
if (node === undefined) return null
@@ -587,6 +590,8 @@ export function SubagentView(props: {
<JobsDrawer
rows={jobRows}
agentCount={agentCount}
jobs={treeJobs.jobs}
observed={observedJob}
openJobId={popover?.kind === 'job' ? popover.jobId : undefined}
onOpenOutput={(row, anchor) => {
setPopover(popover?.kind === 'job' && popover.jobId === row.job.id
-40
View File
@@ -11,7 +11,6 @@ import type { SidechatLiveEvent, SidechatLogEvent, SidechatThreadInfo } from '..
import type {
SidebarCreateTeamTaskRequest,
SidebarChildLiveView,
SidebarJobView,
SidebarSessionEvent,
SidebarTeamMemberView,
SidebarTeamTaskMutationResult,
@@ -104,21 +103,6 @@ export interface FsTextResult { kind: 'text'; content: string; truncated: boolea
* `head` carries the first bytes (base64) for viewer detect sniffing. */
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
/**
* One jobs.output response: the output the MODEL has read so far for the
* job (replayed from the owner session's event log — the model's
* job_output cursor is never touched, so the pane can never steal the
* agent's bytes). `read` is false until the model actually called
* job_output for the job.
*/
export interface JobOutputResult {
text: string
/** True when the host capped the text at its output limit. */
truncated: boolean
/** Whether the model has read the job at least once. */
read: boolean
}
/** The `subagents.live` response: one row per tree child (and the root). */
export type SubagentLiveResult = { live: Record<string, SidebarChildLiveView> }
@@ -348,30 +332,6 @@ export const api = {
/** Cherry-pick one commit onto the current branch. */
gitCherryPick: (scope: SessionScope, hash: string, worktree?: string) =>
call<{ ok: true }>('git.cherry-pick', gitPayload(scope, worktree, { hash })),
/**
* The output the model has read so far for one background job (replayed
* from the owner session's event log — never the model's job_output
* cursor). The scope MUST be the job's OWNER session.
*/
jobOutput: (scope: SessionScope, id: string, signal?: AbortSignal) =>
call<JobOutputResult>('jobs.output', scopePayload(scope, { id }), signal),
/**
* The background-job list of one session (the Tasks page's jobs section and
* the job auto-open trigger). DSH 0.1.7 dropped the client session
* snapshot's jobs mirror, so the registry is read through the plugin's own
* `jobs.list` route. The registry's access fence admits a job to its OWNER
* session (and to unowned jobs) only, so a caller that needs the whole tree
* asks once per tree session. A host without the jobs service answers 503:
* the rejection is the caller's to degrade from (an empty section).
*/
jobsList: (sessionId: string, signal?: AbortSignal) =>
call<{ jobs: SidebarJobView[] }>('jobs.list', { sessionId }, signal),
/** Request cancellation of one background job (live jobs flip to stopping). */
jobKill: (scope: SessionScope, id: string, reason?: string) =>
call<{ ok: true; outcome: 'requested' | 'already-finished' }>('jobs.kill', scopePayload(scope, {
id,
...(reason !== undefined ? { reason } : {}),
})),
/**
* One batch live-preview fetch for the whole Subagent tree. The payload is
* the already-resolved topology ROOT (not a session scope); the host
+69
View File
@@ -0,0 +1,69 @@
/**
* Display copy for the host primitives the sidebar renders OUTSIDE the main
* conversation: `TerminalBlock` (a captured command's output surface) is
* cordis-free and takes every label as a prop, so the owning render site must
* supply them.
*
* One builder, two consumers (the Side Chat tool card and the Tasks page's
* background-job output panel): the two surfaces must read identically, and a
* new required label on the host side has exactly one place to land.
*/
import { t } from './locales.ts'
/** Labels for the sidebar's markdown / diff / read Block chrome. */
export interface BlockLabels {
copy: string
copied: string
collapse: string
collapseAria: string
expand: (hidden: number) => string
expandAria: (hidden: number) => string
}
/**
* The sidebar's Blocks reuse the Side Chat dictionary's chrome family (copy
* pair + the collapse/expand family that is common to every Block kind).
*/
export function blockLabels(): BlockLabels {
return {
copy: t('copy'),
copied: t('copied'),
collapse: t('sideChatBlockCollapse'),
collapseAria: t('sideChatBlockCollapseAria'),
expand: (hidden: number) => t('sideChatBlockExpand', { hidden }),
expandAria: (hidden: number) => t('sideChatBlockExpandAria', { hidden }),
}
}
/** The `TerminalBlockLabels` shape (structural mirror; the host owns the type). */
export interface TerminalLabels {
signal: (signal: string) => string
exitCode: (exitCode: number) => string
noExitCode: string
running: string
failed: string
done: string
copy: string
copied: string
noOutput: string
collapseAria: string
collapse: string
expandAria: (hidden: number) => string
expand: (hidden: number) => string
}
/** Labels for one `TerminalBlock` (a captured command plus its output). */
export function terminalBlockLabels(): TerminalLabels {
return {
...blockLabels(),
signal: (signal: string) => t('sideChatBlockSignal', { signal }),
exitCode: (exitCode: number) => t('sideChatBlockExitCode', { code: exitCode }),
// DSH 0.1.6-alpha.2 added this pill text: a settle the view cannot
// name (killed by an unknown signal, or never started).
noExitCode: t('sideChatBlockNoExitCode'),
running: t('sideChatBlockRunning'),
failed: t('sideChatBlockFailed'),
done: t('sideChatBlockDone'),
noOutput: t('sideChatBlockNoOutput'),
}
}
+125
View File
@@ -0,0 +1,125 @@
/**
* The Tasks page's read of the HOST's client jobs service (`ctx.jobs`).
*
* The plugin used to carry its own three-route jobs transport: a per-session
* `jobs.list` fan-out every 3s, a `job_output` trace replayed from the owner
* session's event log (the registry's only read is the model's CONSUMING
* cursor, which a human pane must never move), and a kill passthrough. The
* host service replaces all three — and this module is the whole data layer of
* that swap, kept apart from the DRAWER so the auto-open trigger in
* `sidebar/use-host-feeds.ts` reads the same feed the page renders.
*
* Structural detection only (the plugin never imports the host package): a
* deployment without the service sees `undefined` and renders no jobs surface.
*/
import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react'
import type { SidebarSessionList } from '../context-types.ts'
import type {
Context,
SidebarClientJobsService,
SidebarJobsSnapshot,
} from '../context-types.ts'
import { type TreeJob } from './subagent-jobs.ts'
/** The empty snapshot a deployment without the jobs service reads. */
const EMPTY_JOBS: SidebarJobsSnapshot = { rows: {}, observed: {} }
/**
* The client jobs service, when this deployment mounts the host jobs UI.
* @param ctx - the client context.
*/
export function clientJobs(ctx: Context): SidebarClientJobsService | undefined {
const service = ctx.get('jobs') as SidebarClientJobsService | undefined
if (service === undefined) return undefined
if (typeof service.watchRows !== 'function' || typeof service.observe !== 'function') return undefined
if (typeof service.kill !== 'function' || service.state === undefined) return undefined
return service
}
/**
* Subscribe to the jobs snapshot (roster + observations).
* @param jobs - the client jobs service (absent → an empty snapshot forever).
*/
export function useJobsSnapshot(jobs: SidebarClientJobsService | undefined): SidebarJobsSnapshot {
const subscribe = useMemo(
() => (callback: () => void) => (jobs === undefined ? () => {} : jobs.state.subscribe(callback)),
[jobs],
)
const read = useCallback(() => (jobs === undefined ? EMPTY_JOBS : jobs.state.getSnapshot()), [jobs])
return useSyncExternalStore(subscribe, read)
}
/**
* Watch (and release) one roster per session, while the page is on screen. The
* host shares a single stream between watchers of one session, so this is
* cheap; the key is the joined id list so a re-render with the same tree does
* not re-subscribe.
* @param jobs - the client jobs service.
* @param sessionIds - the tree's sessions (the root included).
* @param active - whether the page is visible at all.
*/
export function useJobWatchers(
jobs: SidebarClientJobsService | undefined,
sessionIds: readonly string[],
active: boolean,
): void {
const key = sessionIds.join(',')
useEffect(() => {
if (jobs === undefined || !active || key === '') return
const releases = key.split(',').map(sessionId => jobs.watchRows(sessionId))
return () => {
for (const release of releases) release()
}
}, [jobs, key, active])
}
/**
* Observe ONE job's output while its panel is open; the release runs on close.
* @param jobs - the client jobs service.
* @param ownerSessionId - the session the roster carried the job under.
* @param jobId - the observed job (undefined closes the observation).
*/
export function useJobObservation(
jobs: SidebarClientJobsService | undefined,
ownerSessionId: string | undefined,
jobId: string | undefined,
): void {
useEffect(() => {
if (jobs === undefined || jobId === undefined) return
return jobs.observe(ownerSessionId, jobId)
}, [jobs, ownerSessionId, jobId])
}
/**
* One job's rows as the drawer renders them, deduped across the tree.
*
* An UNOWNED job is visible to every watched session, so the union of rosters
* repeats it: rows are keyed by job id and attributed to the watched session
* whose roster carried them (which is also the session the observation and the
* kill go through). Jobs owned by a session outside the tree are dropped.
*
* @param snapshot - the client service snapshot.
* @param byId - the session mirror (owner labels).
* @param treeIds - the tree's session ids (root included).
*/
export function collectRows(
snapshot: SidebarJobsSnapshot,
byId: SidebarSessionList['byId'],
treeIds: ReadonlySet<string>,
): TreeJob[] {
const rows: TreeJob[] = []
const seen = new Set<string>()
for (const [watched, jobs] of Object.entries(snapshot.rows)) {
for (const job of jobs) {
if (seen.has(job.id)) continue
if (job.owner !== undefined && !treeIds.has(job.owner)) continue
seen.add(job.id)
rows.push({
ownerSessionId: watched,
ownerTitle: byId[job.owner ?? watched]?.displayTitle ?? job.owner ?? watched,
job,
})
}
}
return rows
}
+32 -40
View File
@@ -5,30 +5,20 @@
* jump-back). All of it reacts to the host's live feeds for the CURRENT
* session; the sidebar shell only consumes the returned jump-back ref.
*/
import { useCallback, useEffect, useRef } from 'react'
import { useEffect, useRef } from 'react'
import type { Context, SidebarJobView, SidebarSessionList } from '../../context-types.ts'
import type { SidebarStore } from '../state.ts'
import { isNarrowWidth } from '../breakpoints.ts'
import { detectNewDirectSubagent } from '../subagent-detect.ts'
import { detectNewJob } from '../subagent-jobs.ts'
import { api } from '../api.ts'
import { clientJobs, useJobsSnapshot } from '../jobs-client.ts'
import { mountedSessionId } from '../native/surface.ts'
import { usePolling } from '../use-polling.ts'
import { t } from '../locales.ts'
/** How many consecutive reconnect failures stop the agent-opens push loop
* (the loop restarts on session switch). */
const FAILURE_LIMIT = 3
/**
* Background-job poll cadence (ms) for the job auto-open trigger. DSH 0.1.7
* removed the client session snapshot's jobs mirror, so the trigger reads the
* plugin's `jobs.list` route on a timer instead of reacting to a push. One
* in-process registry read per tick bounds the auto-open latency at this
* value.
*/
const JOB_POLL_MS = 2_000
/**
* Subagent auto-open debounce (ms). The host delivers a new child's origin
* and its title in SEPARATE frames: a Side Chat thread's first visible
@@ -235,42 +225,44 @@ export function useHostFeeds(feeds: {
* (0 → N only), ANY new job id triggers: the agent may start several jobs in
* one session, and each should surface.
*
* The list is POLLED: DSH 0.1.7 stopped mirroring background jobs into the
* client session snapshot, so the registry (fenced per OWNER session) is
* read through the plugin's `jobs.list` route. A fresh page load never
* triggers — the FIRST successful read only arms the baseline — and a host
* without the jobs service (503) leaves the baseline unarmed, so the first
* list that does arrive is never mistaken for new work.
* The roster is PUSHED by the host's own client jobs service: one
* reference-counted `watchRows` stream for the current session replaces the
* 3s `jobs.list` poll this used to run. A fresh page load never triggers —
* the FIRST frame only arms the baseline — and a deployment without the
* jobs service leaves the baseline unarmed, so the first roster that does
* arrive is never mistaken for new work.
*
* `detectNewJob` also compares against the moment the watch STARTED: the
* first frame of a session that already had running jobs must not surface
* them as new work.
*/
const jobsService = clientJobs(ctx)
const jobsSnapshot = useJobsSnapshot(jobsService)
const jobBaselineRef = useRef<readonly SidebarJobView[] | undefined>(undefined)
// A session switch voids the previous session's baseline BEFORE the poller
// below restarts on the new id (effects run in declaration order).
useEffect(() => { jobBaselineRef.current = undefined }, [sessionId])
const pollJobs = useCallback(async (signal: AbortSignal): Promise<void> => {
if (sessionId === undefined) return
let jobs: readonly SidebarJobView[]
try {
const result = await api.jobsList(sessionId, signal)
jobs = result.jobs
} catch {
// No jobs service, or a dropped read: keep the last baseline and let the
// next tick retry (never treat an unreadable list as an empty one).
return
const jobWatchStartRef = useRef<number | undefined>(undefined)
useEffect(() => {
if (jobsService === undefined || sessionId === undefined) return
const release = jobsService.watchRows(sessionId)
// A session switch voids the previous session's baseline AND its clock.
jobWatchStartRef.current = Date.now()
jobBaselineRef.current = undefined
return () => {
release()
jobBaselineRef.current = undefined
jobWatchStartRef.current = undefined
}
if (signal.aborted) return
}, [jobsService, sessionId])
useEffect(() => {
if (jobsService === undefined || sessionId === undefined) return
const rows = jobsSnapshot.rows[sessionId] ?? []
const prev = jobBaselineRef.current
jobBaselineRef.current = jobs
jobBaselineRef.current = rows
if (prev === undefined) return
if (!detectNewJob(prev, jobs)) return
if (!detectNewJob(prev, rows, jobWatchStartRef.current)) return
if (!store.getPrefs().autoOpenJobs) return
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
activateTasksPage(ctx, sessionId, { background: true })
}, [sessionId, store, ctx])
usePolling(sessionId !== undefined, pollJobs, {
intervalMs: JOB_POLL_MS,
mode: 'self-scheduling',
immediate: true,
})
}, [jobsService, jobsSnapshot, sessionId, store, ctx])
/**
* Topology jump-back: clicking a subagent node on the Subagent page calls
+19 -31
View File
@@ -9,7 +9,6 @@
* status mapping mirror the official ui-jobs header list.
*/
import type {
SidebarSessionList,
SidebarJobStatus,
SidebarJobView,
} from '../context-types.ts'
@@ -35,42 +34,31 @@ export { treeSessionIds }
/**
* Whether a NEW background job appeared for the current session between two
* consecutive reads of its job list (a job id the previous list lacked).
* Unlike the subagent auto-open (0 → N only), ANY new job id triggers: the
* agent may start several jobs over a session, and each new one should
* surface the Tasks page containing the background-jobs section. A fresh page
* load never triggers — the caller builds its baseline from the first list it
* reads instead of comparing against one (see the poller in
* sidebar/use-host-feeds.ts).
* roster frames (a job id the previous frame lacked). Unlike the subagent
* auto-open (0 → N only), ANY new job id triggers: the agent may start
* several jobs over a session, and each new one should surface the Tasks page
* that contains the background-jobs section.
*
* `since` (epoch ms) is the moment the caller STARTED watching the roster, and
* it is what keeps a page load honest now that the roster arrives as a push
* stream: the host's client jobs model drops a session's key when it sees no
* jobs, so an empty first frame is indistinguishable from "not delivered yet".
* A job that already existed when the watcher opened therefore looks brand new
* to an id-set diff — comparing its start time against `since` filters it out,
* while a job the agent starts afterwards still triggers.
*
* @param prev - the previous frame's rows.
* @param next - the current frame's rows.
* @param since - epoch ms the watch began; omitted skips the age test.
* @returns whether genuinely new work appeared.
*/
export function detectNewJob(
prev: readonly SidebarJobView[],
next: readonly SidebarJobView[],
since?: number,
): boolean {
const prevIds = new Set(prev.map(job => job.id))
return next.some(job => !prevIds.has(job.id))
}
/**
* Collect the background jobs of the whole current tree, owner-labeled. The
* map is keyed by the OWNER session (the fence's unit of access): sessions the
* caller could not read, or that returned nothing, contribute nothing — an
* absent entry is an empty set, never a dropped row of another tree.
*/
export function collectTreeJobs(
byId: SidebarSessionList['byId'],
jobsBySession: Readonly<Record<string, readonly SidebarJobView[]>> | undefined,
rootId: string | undefined,
): TreeJob[] {
const rows: TreeJob[] = []
if (jobsBySession === undefined) return rows
for (const sessionId of treeSessionIds(byId, rootId)) {
const jobs = jobsBySession[sessionId]
if (jobs === undefined || jobs.length === 0) continue
const ownerTitle = byId[sessionId]?.displayTitle ?? sessionId
for (const job of jobs) rows.push({ ownerSessionId: sessionId, ownerTitle, job })
}
return rows
return next.some(job => !prevIds.has(job.id) && (since === undefined || job.startedAt >= since))
}
/**