fix(subagent): degrade to summary rows when root catalog never hydrates
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:
+111
-3
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user