/** * Subagent page: the FULL agent topology of the current tree's main session. * * The root is resolved by walking the durable parent chain upward from the * current session to the first non-subagent session — the MAIN session — and * every subagent under it shares this one topology view, no matter how deep * the current selection is (including a subagent transcript opened in the * main view). The main agent renders as the root node card (click it to jump * back to the main session), with its subagents hanging below it in clearly * LAYERED levels: tree connector lines (first level included) and per-level * indentation show the hierarchy, and the currently-open session is * highlighted in place. Every branch is expanded automatically (lazy * catalogs hydrate on demand and consume live membership while visible). * * Each node card carries live status (state dot, durable label, mode and * activity); while a child RUNS, its card additionally shows the LAST text * output and LAST tool call pulled from its history tail, auto-refreshing * every few seconds while the page is visible. Clicking a card jumps * straight into the child transcript (`openSubagent`); the page stays open * and the topology remains rooted at the main session. */ import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' import { IconRefreshOutline14, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context, SidebarSessionList, SidebarSessionSummary, SidebarSubagentAddress, SidebarSubagentCatalog, SidebarSubagentChildEntry, SidebarSubagentDiagnosticEntry, SidebarJobView, } from '../context-types.ts' import { collectBranchIds, countSubagentDescendants, isSideThreadSummary, rootAncestor, } from './subagent-detect.ts' import { type LastActivity } from '../subagent-activity.ts' import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts' import { collectTreeJobs, formatJobDuration, isJobLive, orderJobs, jobDotState, jobStatusLabel, type TreeJob, } from './subagent-jobs.ts' import { api, type JobOutputResult } from './api.ts' import { usePolling } from './use-polling.ts' import { IconStopOutline16 } from './icons.tsx' import { t } from './locales.ts' import css from './SubagentView.module.css' /** Refresh cadence of the live "last text + tool call" lines while a child runs. */ const POLL_MS = 3000 /** Preview cap of one tool-call argument line. */ const ARGS_PREVIEW = 60 /** Refresh cadence of an expanded job-output panel 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 direct subagent children of one parent (durable `origin` rows; * Side Chat threads ride the same origin but are tab-strip conversations, * never topology). */ function directChildren( byId: Readonly>, parentSessionId: string, ): SidebarSessionSummary[] { return Object.values(byId).filter( summary => summary.origin === 'subagent' && summary.parentId === parentSessionId && !isSideThreadSummary(summary), ) } /** Human label of one catalog child: durable label, then summary title, then id. */ function childLabel( entry: SidebarSubagentChildEntry, summary: SidebarSessionSummary | undefined, ): string { return entry.label ?? summary?.displayTitle ?? entry.id } function diagnosticReason(entry: SidebarSubagentDiagnosticEntry): string { switch (entry.reason) { case 'corrupt': return t('subagentDiagCorrupt') case 'unsupported': return t('subagentDiagUnsupported') case 'unavailable': return t('subagentDiagUnavailable') } } /** The secondary line of one card: title · mode · activity (skips empty parts). */ function cardSecondary( summary: SidebarSessionSummary | undefined, entry: SidebarSubagentChildEntry, ): string { return [ summary?.displayTitle, entry.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable'), entry.activity === 'running' ? t('subagentRunning') : t('subagentInactive'), ].filter(Boolean).join(' · ') } /** First `limit` characters with an ellipsis when truncated. */ function preview(text: string, limit: number): string { return text.length > limit ? `${text.slice(0, limit)}…` : text } /** Collapse whitespace for the single-paragraph live-text preview. */ function flatten(text: string): string { return text.replace(/\s+/g, ' ').trim() } /** Disabled "loading…" cards backed by the summary mirror while a catalog hydrates. */ function CatalogLoadingRows(props: { parentSessionId: string byId: Readonly> level: number }) { const { parentSessionId, byId, level } = props const children = directChildren(byId, parentSessionId) if (children.length === 0) { return
{t('loading')}
} return ( <> {children.map(summary => (
{t('loading')}
))} ) } /** * The live lines of one RUNNING subagent card: a pure presentation of the * batch `subagents.live` activity. The polling lives in one place (the * SubagentView hook), not per card. A running child with neither output yet * reads "thinking…". */ function SubagentLiveLines(props: { live: LastActivity | undefined }) { const { live } = props if (live?.text === undefined && live?.tool === undefined) { return {t('subagentThinking')} } return ( <> {live.tool !== undefined && ( {live.tool.name} {live.tool.args !== '' && ( {preview(live.tool.args, ARGS_PREVIEW)} )} )} {live.text !== undefined && ( {flatten(live.text)} )} ) } /** * One shared live-preview poller for the whole Subagent tree. Unlike the old * per-card `subagents.history` timers, this sends at most ONE `subagents.live` * request at a time (the shared poller's self-scheduling mode arms the next * tick only after the previous request settles, so a slow host never sees * abort/restart storms); a response settling after the poller stopped (page * hidden, tree re-rooted) is dropped via the aborted signal. */ function useSubagentLive( rootId: string | undefined, active: boolean, ): Readonly> { const [live, setLive] = useState>({}) // A new tree must never inherit another root's live previews. useEffect(() => { setLive({}) }, [rootId]) const poll = useCallback(async (signal: AbortSignal): Promise => { if (rootId === undefined) return const result = await api.subagentsLive(rootId, signal) if (!signal.aborted) setLive(result.live) }, [rootId]) usePolling(rootId !== undefined && active, poll, { intervalMs: POLL_MS, mode: 'self-scheduling', immediate: true, }) return live } interface RowsProps { parentSessionId: string catalog: SidebarSubagentCatalog | undefined catalogs: Readonly> byId: Readonly> level: number /** The currently-open session id (highlighted in the topology). */ currentSessionId: string /** The batch live-preview map (child id → latest activity). */ live: Readonly> openChild: (address: SidebarSubagentAddress) => void refresh: (parentSessionId: string) => void } /** Render one topology level; branches are always expanded (lazy catalogs). */ function CatalogRows({ parentSessionId, catalog, catalogs, byId, level, currentSessionId, live, openChild, refresh, }: RowsProps) { const emptyLoading = catalog?.state === 'loading' && catalog.entries.length === 0 // Side Chat threads are honest catalog citizens (durable descriptor, 'Side: ' // label) but they are NOT subagent topology — filter them out here (the tab // strip owns them). Legacy threads created before the descriptor fix still // arrive as corrupt diagnostics; they are recognized by summary title. const visibleEntries = (catalog?.entries ?? []).filter((entry) => { if (entry.kind === 'child') return !(entry.label?.startsWith(SIDE_LABEL_PREFIX) ?? false) return !(byId[entry.id]?.displayTitle.startsWith(SIDE_LABEL_PREFIX) ?? false) }) return ( <> {emptyLoading && ( )} {catalog?.state === 'error' && (
{catalog.error?.message ?? t('error')}
)} {visibleEntries.map((entry) => { if (entry.kind === 'diagnostic') { return (
{entry.id} {diagnosticReason(entry)}
) } const childCatalog = catalogs[entry.id] const knownLeaf = !entry.hasChildren const summary = byId[entry.id] const label = childLabel(entry, summary) const secondary = cardSecondary(summary, entry) const childLoading = childCatalog === undefined || (childCatalog.state === 'loading' && childCatalog.entries.length === 0) const address: SidebarSubagentAddress = { parentSessionId, childSessionId: entry.id, mode: entry.mode, } const current = entry.id === currentSessionId return (
{ openChild(address) }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() event.stopPropagation() openChild(address) } }} > {label} {secondary} {entry.activity === 'running' && ( )}
{!knownLeaf && (
{childCatalog === undefined ? ( ) : ( )}
)}
) })} ) } /** * The shared output dock of the jobs section: ONE pane at the bottom of the * sidebar body (sticky, terminal-like) shows the SELECTED job's output as * the MODEL has read it 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 model's `job_output` cursor is never touched — the * pane can never steal the agent's bytes, and it stays empty until the * agent reads the job. A single dock — not a panel per row — keeps the * job list compact and stable when many jobs are running. */ function JobOutputPane(props: { ownerSessionId: string job: SidebarJobView /** The page is visible (active tab + open panel): skip polling otherwise. */ active: boolean onClose: () => void }) { const { ownerSessionId, job, active, onClose } = props const [state, setState] = useState<'loading' | JobOutputResult | 'error'>('loading') const controllerRef = useRef(undefined) const preRef = useRef(null) 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 dock that never loaded anything shows an error. setState(current => (current === 'loading' ? 'error' : current)) } }, [ownerSessionId, job.id]) useEffect(() => { void load() if (!active || !isJobLive(job)) return const timer = window.setInterval(() => { void load() }, JOB_POLL_MS) return () => { window.clearInterval(timer) } // isJobLive reads only job.status; whole-job identity 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() }, []) // Terminal-tail behavior: while the job runs, each refresh pins the view // to the newest output; a settled dock leaves scrolling to the reader. useEffect(() => { if (!isJobLive(job) || typeof state !== 'object' || state.text.length === 0) return const pre = preRef.current if (pre !== null) pre.scrollTop = pre.scrollHeight // Same as the poll effect above: only the status transition matters. // eslint-disable-next-line react-hooks/exhaustive-deps }, [state, job.status]) return (
{job.label} {jobStatusLabel(job.status, t)} {job.detail !== undefined && job.detail !== '' ? ` · ${job.detail}` : ''}
{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')}
} )}
) } /** * The background-job section of the Subagent page: every job of the whole * current tree (main agent + subagents, owner-labeled), fed by the harness * `session/jobs` push mirror. Clicking a row feeds its model-read output to * the shared bottom dock (event replay — never the model's cursor); live * rows carry a two-click-confirm kill button. Renders nothing while the * tree has no jobs. */ function JobsSection(props: { byId: SidebarSessionList['byId'] jobsBySession: SidebarSessionList['jobsBySession'] rootId: string | undefined /** The page is visible (active tab + open panel): skip polling otherwise. */ active: boolean }) { const { byId, jobsBySession, rootId, active } = props const rows = useMemo( () => orderJobs(collectTreeJobs(byId, jobsBySession, rootId)), [byId, jobsBySession, rootId], ) const [selectedId, setSelectedId] = useState(undefined) const [armedId, setArmedId] = useState(undefined) const [killingId, setKillingId] = useState(undefined) const [killErrorId, setKillErrorId] = useState(undefined) // The duration clock only runs while a live row is on screen. const [now, setNow] = useState(() => Date.now()) const selectedRow = useMemo( () => (selectedId === undefined ? undefined : rows.find(row => row.job.id === selectedId)), [rows, selectedId], ) 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]) // The docked output pane follows its job: when the selected job leaves // the mirror (settled and dropped, or the tree switched), close the dock. useEffect(() => { if (selectedId !== undefined && selectedRow === undefined) setSelectedId(undefined) }, [selectedId, selectedRow]) // NOTE: every hook must live ABOVE the empty-state return — a hook below it // would flip this component's hook count when the mirror empties and crash // React with "Rendered fewer hooks than expected" (the #300 regression). 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 ( <>
{t('jobs')} {countLabel}
    {rows.map((row) => { const { job } = row const live = isJobLive(job) const selected = selectedId === job.id 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 secondary = [ ...(multiOwner ? [row.ownerTitle] : []), jobStatusLabel(job.status, t), ...(job.detail !== undefined && job.detail !== '' ? [job.detail] : []), formatJobDuration(elapsed, t), ].filter(Boolean).join(' · ') return (
  • {job.status === 'running' && ( )} {killFailed && {t('jobKillError')}}
  • ) })}
{selectedRow !== undefined && ( { setSelectedId(undefined) }} /> )} ) } /** * The sidebar's Subagent topology page. * @param props - current session id, whether the page is actually visible * (active tab + open panel), the client context, and an optional * jump-notify hook fired right before `openSubagent` (lets the sidebar * shell re-open the Subagent page after the conversation switch lands on * the child session). * @returns the main agent's topology tree, or the empty/error/loading states. */ export function SubagentView(props: { sessionId: string active: boolean ctx: Context onOpenChild?: (address: SidebarSubagentAddress) => void }) { const { sessionId, active, ctx, onOpenChild } = props const sessions = ctx.sessions // The same list feed the official catalog consumes (byId lineage + the // lazy per-parent catalogs). Older DSH snapshots without the subagent seam // simply leave these surfaces empty — the page degrades to the empty state. const list = useSyncExternalStore( useMemo(() => (callback: () => void) => sessions.list.subscribe(callback), [sessions]), useCallback(() => sessions.list.getSnapshot(), [sessions]), ) const byId = list.byId // Memoized so the empty-catalog fallback keeps a stable identity — a fresh // `{}` per render would invalidate every catalog-dependent memo/effect. const catalogs = useMemo(() => list.subagentsByParent ?? {}, [list.subagentsByParent]) // The topology root: the main agent of the current session's tree. const rootId = useMemo(() => rootAncestor(byId, sessionId), [byId, sessionId]) const rootCatalog = rootId === undefined ? undefined : catalogs[rootId] const rootSummary = rootId === undefined ? undefined : byId[rootId] const live = useSubagentLive(rootId, active) /** Catalog owners currently consuming live membership updates. */ const observedRef = useRef(new Set()) const observe = useCallback((parentSessionId: string, open: boolean): void => { sessions.setSubagentCatalogOpen?.(parentSessionId, open) if (open) observedRef.current.add(parentSessionId) else observedRef.current.delete(parentSessionId) }, [sessions]) // While the page is visible the topology root consumes live membership; a // root change (switching to another main agent's tree) or the page hiding // (tab switched away / panel collapsed) releases everything observed. useEffect(() => { if (rootId === undefined || !active) return observe(rootId, true) return () => { // The cleanup must release everything observed AT cleanup time (the set // mutates as branches open), so reading the ref here is the point. // eslint-disable-next-line react-hooks/exhaustive-deps for (const parentSessionId of observedRef.current) { sessions.setSubagentCatalogOpen?.(parentSessionId, false) } observedRef.current.clear() } }, [rootId, active, observe, sessions]) // Every branch of the always-expanded topology consumes live membership // (add-only: a branch stays observed until the root changes or the page // hides, which releases the whole set via the root effect's cleanup). const branches = useMemo(() => collectBranchIds(catalogs, rootId), [catalogs, rootId]) useEffect(() => { if (!active) return for (const id of branches) { if (!observedRef.current.has(id)) observe(id, true) } }, [branches, active, observe]) // Unobserve everything on unmount (the host stops refreshing unused catalogs). useEffect(() => () => { for (const parentSessionId of observedRef.current) { sessions.setSubagentCatalogOpen?.(parentSessionId, false) } observedRef.current.clear() }, [sessions]) const openChild = useCallback((address: SidebarSubagentAddress): void => { // Notify the shell first: the jump switches the sidebar to the child // session's own layout, and the shell re-opens the Subagent page on top // of it (the topology stays rooted at the main agent with the child // highlighted) — the README "page stays open" contract. onOpenChild?.(address) try { sessions.openSubagent?.(address) } catch (error) { console.error('[dsh-better-sidebar] openSubagent failed:', error) } }, [sessions, onOpenChild]) /** Jump back to the main agent (the topology root) from its node. */ const openMain = useCallback((): void => { if (rootId === undefined) return try { sessions.open?.(rootId) } catch (error) { console.error('[dsh-better-sidebar] open session failed:', error) } }, [sessions, rootId]) const refresh = useCallback((parentSessionId: string): void => { void sessions.refreshSubagents?.(parentSessionId) }, [sessions]) const totals = useMemo( () => rootId === undefined ? { count: 0, runningCount: 0 } : countSubagentDescendants(byId, rootId), [byId, rootId], ) // Session summaries can announce membership before the descriptor-backed // catalog catches up (or a catalog that just went ready is still empty). const summaryBackedLoading = rootId !== undefined && (rootCatalog === undefined || (rootCatalog.state === 'ready' && rootCatalog.entries.length === 0)) && directChildren(byId, rootId).length > 0 const readyEmpty = rootCatalog?.state === 'ready' && rootCatalog.entries.length === 0 && directChildren(byId, rootId ?? '').length === 0 const countLabel = totals.count === 0 ? undefined : totals.runningCount > 0 ? t('subagentCountRunning', { count: totals.count, running: totals.runningCount }) : t('subagentCount', { count: totals.count }) /** Arrow-key tree navigation over the visible rows (official catalog recipe). */ const bodyRef = useRef(null) const focusAt = useCallback((index: number): void => { const items = bodyRef.current?.querySelectorAll( '[role="treeitem"]:not([aria-disabled="true"])', ) ?? [] if (items.length === 0) return items[(index + items.length) % items.length]?.focus() }, []) const onTreeKeyDown = useCallback((event: KeyboardEvent): void => { const items = bodyRef.current?.querySelectorAll( '[role="treeitem"]:not([aria-disabled="true"])', ) ?? [] const index = Array.prototype.indexOf.call(items, document.activeElement) if (event.key === 'ArrowDown') { event.preventDefault() focusAt(index + 1) } else if (event.key === 'ArrowUp') { event.preventDefault() focusAt(index < 0 ? items.length - 1 : index - 1) } else if (event.key === 'Home') { event.preventDefault() focusAt(0) } else if (event.key === 'End') { event.preventDefault() focusAt(items.length - 1) } }, [focusAt]) return (
{t('subagent')} {rootSummary?.displayTitle !== undefined && rootSummary.displayTitle !== '' ? ` · ${rootSummary.displayTitle}` : ''} {countLabel !== undefined && {countLabel}}
{rootId !== undefined && rootSummary !== undefined && (
{ if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() event.stopPropagation() openMain() } }} > {rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} {`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`}
)} {rootId !== undefined && (
{summaryBackedLoading && ( )} {!summaryBackedLoading && ( )}
)} {readyEmpty && (
{t('subagentEmpty')}
{t('subagentEmptyDesc')}
)}
) }