/** * 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. * * 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. * * Visual language: Tailwind utilities over the shadcn tokens * (src/client/ui/theme.css) with the vendored Collapsible / ScrollArea / * Button for the interactive shells. Hierarchy comes from a 1px `border-border` * hairline plus the surface ladder (`bg-background` → `bg-muted` on hover) — * the static drawer carries no shadow; the only float is the popover card, * whose surface/shadow belong to AnchoredPopover. Body copy is text-sm, meta * text-xs, identifiers/durations `font-mono tabular-nums`. * * Every non-shadcn control is a host primitive (Switch / StateDot), and every * icon is a host `IconXxx` glyph — the badges are the vendored shadcn `Badge` * on its stock variants. */ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { IconChevronUpOutline14, IconCopyOutline16, IconStopFill16, StateDot, Switch, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SidebarJobView } from '../context-types.ts' import { formatJobDuration, isJobLive, jobDotState, jobStatusLabel, type TreeJob, } from './subagent-jobs.ts' import { api, type JobOutputResult } from './api.ts' import { t } from './locales.ts' import { Badge } from './ui/badge.tsx' import { Button } from './ui/button.tsx' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible.tsx' import { ScrollArea } from './ui/scroll-area.tsx' import { cn } from './ui/utils.ts' /** 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. */ export const JOBS_DRAWER_COLLAPSE_AT = 8 /** * The status badge's stock shadcn variant: running = `default` (the accent), * failed = `destructive`, settled/ending = `secondary`. */ function statusVariant(job: SidebarJobView): 'default' | 'secondary' | 'destructive' { if (job.status === 'running') return 'default' if (job.status === 'failed') return 'destructive' return 'secondary' } export interface JobsDrawerProps { rows: readonly TreeJob[] /** 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). */ onOpenOutput(row: TreeJob, anchor: HTMLElement): void /** The job whose output popover is currently open (row highlight). */ openJobId?: string } export function JobsDrawer(props: JobsDrawerProps): ReactNode { const { rows, agentCount, onOpenOutput, openJobId } = props const autoOpen = agentCount < JOBS_DRAWER_COLLAPSE_AT /** Manual override; undefined = follow the auto rule. */ const [manualOpen, setManualOpen] = useState(undefined) const open = manualOpen ?? autoOpen const [armedId, setArmedId] = useState(undefined) const [killingId, setKillingId] = useState(undefined) const [killErrorId, setKillErrorId] = useState(undefined) const [now, setNow] = useState(() => Date.now()) const liveCount = useMemo( () => rows.reduce((count, row) => count + (isJobLive(row.job) ? 1 : 0), 0), [rows], ) const multiOwner = useMemo( () => new Set(rows.map(row => row.ownerSessionId)).size > 1, [rows], ) // The kill button stays armed only briefly; a stray click must never kill. useEffect(() => { if (armedId === undefined) return const timer = window.setTimeout(() => { setArmedId(undefined) }, JOB_KILL_ARM_MS) return () => { window.clearTimeout(timer) } }, [armedId]) useEffect(() => { if (liveCount === 0) return setNow(Date.now()) const timer = window.setInterval(() => { setNow(Date.now()) }, 1_000) return () => { window.clearInterval(timer) } }, [liveCount]) const kill = useCallback(async (row: TreeJob): Promise => { setKillingId(row.job.id) setKillErrorId(undefined) try { await api.jobKill({ sessionId: row.ownerSessionId }, row.job.id) } catch { setKillErrorId(row.job.id) } finally { setKillingId(undefined) setArmedId(undefined) } }, []) if (rows.length === 0) return null const countLabel = liveCount > 0 ? t('jobsCountRunning', { count: rows.length, running: liveCount }) : t('jobsCount', { count: rows.length }) return ( { setManualOpen(next) }} role="region" aria-label={t('jobs')} className="z-[5] flex-none border-t border-border bg-background" > {/* The bar keeps the drawer's whole toggle affordance (title, running tally, count line) and Radix owns aria-expanded / aria-controls. */} {t('jobs')} {liveCount > 0 ? liveCount : rows.length} {countLabel} {!autoOpen && (
{t('jobsAutoCollapsed')}
)} {/* Bounded log surface: one row per job, hairline-separated by the rows' own 4px rhythm rather than per-row borders. The height is definite (not `max-h`) because ScrollArea's viewport is `size-full`: a bare max-height leaves the viewport content-sized, so the rows would spill out of the drawer instead of scrolling. */}
{rows.map((row) => { const { job } = row const live = isJobLive(job) const armed = armedId === job.id const killing = killingId === job.id const killFailed = killErrorId === job.id const elapsed = live ? now - job.startedAt : (job.finishedAt ?? job.startedAt) - job.startedAt const duration = formatJobDuration(elapsed, t) // The owner/detail tail; the duration is its own tabular-nums // cell, while the accessible name keeps the exact // `owner · detail · duration` string it always had. const context = [ ...(multiOwner ? [row.ownerTitle] : []), ...(job.detail !== undefined && job.detail !== '' ? [job.detail] : []), ].join(' · ') const secondary = [context, duration].filter(Boolean).join(' · ') return (
{job.status === 'running' && ( )} {killFailed && ( {t('jobKillError')} )}
) })}
) } /** * The output popover content of one job: the text the MODEL has read so far * (replayed from the owner session's event log), refreshed every * {@link JOB_POLL_MS} while the job runs and the page is visible. The reader * can copy the output and turn the terminal-style tail off; the kill action * keeps its two-click confirm. * * The card fills the AnchoredPopover surface (rounded-lg + border + shadow * live there, since that host owns the float) and only places its content * inside it: header, title line, meta line, the terminal tail, and the action * footer. `data-popover-handle` / `data-popover-no-drag` keep the drag * contract unchanged — the body drags, the controls do not. */ export function JobOutputPopoverContent(props: { ownerSessionId: string job: SidebarJobView active: boolean }): ReactNode { const { ownerSessionId, job, active } = props const [state, setState] = useState<'loading' | JobOutputResult | 'error'>('loading') 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(undefined) const preRef = useRef(null) const live = isJobLive(job) const load = useCallback(async (): Promise => { 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() }, []) // The armed kill disarms itself, like the drawer's button. useEffect(() => { if (!armed) return const timer = window.setTimeout(() => { setArmed(false) }, JOB_KILL_ARM_MS) return () => { window.clearTimeout(timer) } }, [armed]) // Terminal-tail behavior: each refresh 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 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]) const text = typeof state === 'object' ? state.text : '' /** Two-click kill from the popover (then re-read so the row settles). */ const kill = async (): Promise => { setKilling(true) setKillFailed(false) try { await api.jobKill({ sessionId: ownerSessionId }, job.id) setArmed(false) await load() } catch { setKillFailed(true) } finally { setKilling(false) } } return ( // `dsw-tasks`: this card renders inside AnchoredPopover's portal at // document.body — OUTSIDE the page root — so it re-declares the page root // class for the scoped base reset (see TaskWindow.tsx).
{t('jobs')}
{job.label} {jobStatusLabel(job.status, t)}
{job.kind} {job.detail !== undefined && job.detail !== '' && {job.detail}} {t('jobDragHint')}
{state === 'loading' &&
{t('loading')}
} {state === 'error' &&
{t('jobOutputError')}
} {typeof state === 'object' && ( <> {state.text.length > 0 ? (
                {state.text}
              
) : state.read ?
{t('jobNoOutput')}
:
{t('jobNotReadYet')}
} {state.truncated &&
{t('jobOutputTruncated')}
} )}
{live && ( )}
{killFailed &&
{t('jobKillError')}
}
) }