diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx index ae92dc6..c2e410c 100644 --- a/src/client/SubagentView.tsx +++ b/src/client/SubagentView.tsx @@ -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> + 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
{t('loading')}
+ } + 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 ( +
{ openChild(address) }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + event.stopPropagation() + openChild(address) + } + }} + > + + + {label} + {secondary} + +
+ ) + })} + + ) +} + /** * 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: {
{rootId !== undefined && rootSummary !== undefined && (
)} {rootId !== undefined && ( -
- {summaryBackedLoading && ( +
+ {summaryBackedLoading && !degraded && ( )} + {summaryBackedLoading && degraded && ( + + )} {!summaryBackedLoading && (