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
|
const JOB_POLL_MS = 2000
|
||||||
/** How long the kill button stays armed before it needs re-confirming. */
|
/** How long the kill button stays armed before it needs re-confirming. */
|
||||||
const JOB_KILL_ARM_MS = 3000
|
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;
|
/** The direct subagent children of one parent (durable `origin` rows;
|
||||||
* Side Chat threads ride the same origin but are tab-strip conversations,
|
* 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
|
* The live lines of one RUNNING subagent card: a pure presentation of the
|
||||||
* batch `subagents.live` activity. The polling lives in one place (the
|
* batch `subagents.live` activity. The polling lives in one place (the
|
||||||
@@ -733,6 +811,27 @@ export function SubagentView(props: {
|
|||||||
const summaryBackedLoading = rootId !== undefined
|
const summaryBackedLoading = rootId !== undefined
|
||||||
&& (rootCatalog === undefined || (rootCatalog.state === 'ready' && rootCatalog.entries.length === 0))
|
&& (rootCatalog === undefined || (rootCatalog.state === 'ready' && rootCatalog.entries.length === 0))
|
||||||
&& directChildren(byId, rootId).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'
|
const readyEmpty = rootCatalog?.state === 'ready'
|
||||||
&& rootCatalog.entries.length === 0
|
&& rootCatalog.entries.length === 0
|
||||||
&& directChildren(byId, rootId ?? '').length === 0
|
&& directChildren(byId, rootId ?? '').length === 0
|
||||||
@@ -800,7 +899,7 @@ export function SubagentView(props: {
|
|||||||
<div
|
<div
|
||||||
role="tree"
|
role="tree"
|
||||||
aria-label={t('subagent')}
|
aria-label={t('subagent')}
|
||||||
aria-busy={summaryBackedLoading || undefined}
|
aria-busy={(summaryBackedLoading && !degraded) || undefined}
|
||||||
>
|
>
|
||||||
{rootId !== undefined && rootSummary !== undefined && (
|
{rootId !== undefined && rootSummary !== undefined && (
|
||||||
<div
|
<div
|
||||||
@@ -834,10 +933,19 @@ export function SubagentView(props: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{rootId !== undefined && (
|
{rootId !== undefined && (
|
||||||
<div className={css.subagentChildren} role="group" aria-busy={summaryBackedLoading || undefined}>
|
<div className={css.subagentChildren} role="group" aria-busy={(summaryBackedLoading && !degraded) || undefined}>
|
||||||
{summaryBackedLoading && (
|
{summaryBackedLoading && !degraded && (
|
||||||
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
|
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
|
||||||
)}
|
)}
|
||||||
|
{summaryBackedLoading && degraded && (
|
||||||
|
<SummaryFallbackRows
|
||||||
|
parentSessionId={rootId}
|
||||||
|
byId={byId}
|
||||||
|
level={1}
|
||||||
|
currentSessionId={sessionId}
|
||||||
|
openChild={openChild}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!summaryBackedLoading && (
|
{!summaryBackedLoading && (
|
||||||
<CatalogRows
|
<CatalogRows
|
||||||
parentSessionId={rootId}
|
parentSessionId={rootId}
|
||||||
|
|||||||
Reference in New Issue
Block a user