fix(subagent): degrade to summary rows when root catalog never hydrates
CI / ci (pull_request) Canceled after 0s
CI / ci-windows (pull_request) Canceled after 0s
CI / plugin-mount (pull_request) Canceled after 0s

The Subagent/任务管理 page pinned on a permanent '加载中…' spinner
whenever the host's per-parent subagent catalog (subagentsByParent)
never hydrated for the root session — rootCatalog stayed undefined
while the session mirror already listed the children, so
summaryBackedLoading was true forever (SubagentView.tsx summaryBacked
gate). Root cause: a host-side hydrate gap (e.g. the
agent/disposed listener TypeError breaking the refresh chain) leaves
setSubagentCatalogOpen unanswered, and the UI had no escape.

Add a 5s hydrate timeout: once setSubagentCatalogOpen has had a chance
to land and the root catalog is still un-hydrated, render the known
children from the session mirror as CLICKABLE summary rows
(SummaryFallbackRows) instead of the infinite spinner. The timer resets
on root switch / catalog hydrate / page hidden. mode is inferred from
the summary's running flag (best-effort transport hint).

Verified: lint 0 / typecheck 0 / test 131 files·1385 passed / build 0.
Rollback: git revert <this-sha> or reset SubagentView.tsx to 99bef0f.
This commit is contained in:
figmar
2026-10-13 09:28:47 +08:00
parent 99bef0f877
commit 043340eb02
+111 -3
View File
@@ -66,6 +66,15 @@ const ARGS_PREVIEW = 60
const JOB_POLL_MS = 2000
/** How long the kill button stays armed before it needs re-confirming. */
const JOB_KILL_ARM_MS = 3000
/**
* How long the root catalog may stay un-hydrated before the page stops
* blocking on "loading…" and degrades to the summary-mirror rows. A catalog
* that never hydrates (host seam gap / a failed `agent/disposed` listener
* breaking the refresh chain) must not pin the Subagent page on a permanent
* loading spinner — the session summaries are already in the mirror, so the
* tree degrades to clickable summary rows instead.
*/
const CATALOG_HYDRATE_TIMEOUT_MS = 5000
/** The direct subagent children of one parent (durable `origin` rows;
* Side Chat threads ride the same origin but are tab-strip conversations,
@@ -150,6 +159,75 @@ function CatalogLoadingRows(props: {
)
}
/**
* Degrade rows rendered when the root catalog never hydrates within
* {@link CATALOG_HYDRATE_TIMEOUT_MS}: the session summaries are already in the
* mirror (`byId`), so instead of pinning the page on a permanent "loading…"
* spinner we render the known children as CLICKABLE rows that jump to the
* child session. The address `mode` is inferred from the summary's `running`
* flag (a running agent is continuable; a settled one is one-shot) — a
* best-effort transport hint, since the durable catalog `mode` is unavailable
* once hydration has given up.
*/
function SummaryFallbackRows(props: {
parentSessionId: string
byId: Readonly<Record<string, SidebarSessionSummary>>
level: number
currentSessionId: string
openChild: (address: SidebarSubagentAddress) => void
}) {
const { parentSessionId, byId, level, currentSessionId, openChild } = props
const children = directChildren(byId, parentSessionId)
if (children.length === 0) {
return <div className={css.subagentEmpty}>{t('loading')}</div>
}
return (
<>
{children.map(summary => {
const address: SidebarSubagentAddress = {
parentSessionId,
childSessionId: summary.id,
mode: summary.running === true ? 'continuable' : 'one-shot',
}
const current = summary.id === currentSessionId
const label = summary.displayTitle !== '' ? summary.displayTitle : summary.id
const secondary = [
summary.displayTitle,
summary.running === true ? t('subagentRunning') : t('subagentInactive'),
].filter(Boolean).join(' · ')
return (
<div
key={summary.id}
role="treeitem"
tabIndex={0}
aria-level={level}
aria-label={`${label} ${secondary}`}
aria-current={current ? 'true' : undefined}
className={clsx(css.subagentRow, current && css.subagentRowActive)}
onClick={() => { openChild(address) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
event.stopPropagation()
openChild(address)
}
}}
>
<StateDot
state={summary.running === true ? 'ongoing' : 'done'}
className={css.subagentDot}
/>
<span className={css.subagentContent}>
<span className={css.subagentLabel}>{label}</span>
<span className={css.subagentSecondary}>{secondary}</span>
</span>
</div>
)
})}
</>
)
}
/**
* The live lines of one RUNNING subagent card: a pure presentation of the
* batch `subagents.live` activity. The polling lives in one place (the
@@ -733,6 +811,27 @@ export function SubagentView(props: {
const summaryBackedLoading = rootId !== undefined
&& (rootCatalog === undefined || (rootCatalog.state === 'ready' && rootCatalog.entries.length === 0))
&& directChildren(byId, rootId).length > 0
// Hydrate timeout: once `setSubagentCatalogOpen(rootId, true)` has had a
// chance to land and the root catalog is STILL un-hydrated (or an empty
// ready), stop blocking on "loading…" and degrade to the summary-mirror
// rows. The timer resets when the root switches (a new tree) or when the
// catalog actually hydrates (`summaryBackedLoading` goes false), and only
// fires while the page is visible (a hidden page is not "loading").
const [catalogDegrade, setCatalogDegrade] = useState(false)
useEffect(() => {
setCatalogDegrade(false)
}, [rootId])
useEffect(() => {
if (!summaryBackedLoading || !active) {
setCatalogDegrade(false)
return
}
const timer = window.setTimeout(() => { setCatalogDegrade(true) }, CATALOG_HYDRATE_TIMEOUT_MS)
return () => { window.clearTimeout(timer) }
}, [summaryBackedLoading, active])
// `degraded` is the gate: summary-mirror rows show instead of the
// permanent "loading…" spinner once the hydrate timeout has fired.
const degraded = summaryBackedLoading && catalogDegrade
const readyEmpty = rootCatalog?.state === 'ready'
&& rootCatalog.entries.length === 0
&& directChildren(byId, rootId ?? '').length === 0
@@ -800,7 +899,7 @@ export function SubagentView(props: {
<div
role="tree"
aria-label={t('subagent')}
aria-busy={summaryBackedLoading || undefined}
aria-busy={(summaryBackedLoading && !degraded) || undefined}
>
{rootId !== undefined && rootSummary !== undefined && (
<div
@@ -834,10 +933,19 @@ export function SubagentView(props: {
</div>
)}
{rootId !== undefined && (
<div className={css.subagentChildren} role="group" aria-busy={summaryBackedLoading || undefined}>
{summaryBackedLoading && (
<div className={css.subagentChildren} role="group" aria-busy={(summaryBackedLoading && !degraded) || undefined}>
{summaryBackedLoading && !degraded && (
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
)}
{summaryBackedLoading && degraded && (
<SummaryFallbackRows
parentSessionId={rootId}
byId={byId}
level={1}
currentSessionId={sessionId}
openChild={openChild}
/>
)}
{!summaryBackedLoading && (
<CatalogRows
parentSessionId={rootId}