diff --git a/src/client/subagent-detect.ts b/src/client/subagent-detect.ts index f45b3ad..a8668ef 100644 --- a/src/client/subagent-detect.ts +++ b/src/client/subagent-detect.ts @@ -9,23 +9,20 @@ * - {@link countSubagentDescendants}: uninterrupted subagent-origin lineage * totals (mirror of the official `indexSubagentDescendants` over the * plugin's own summary rows). + * + * The lineage walks themselves ({@link isSideThreadSummary}, {@link + * rootAncestor}, {@link countSubagentDescendants}) live in + * ./subagent-lineage.ts — the single shared walk implementation — and are + * re-exported below for their established import sites. */ import type { SidebarSessionList, - SidebarSessionSummary, SidebarSubagentCatalog, } from '../context-types.ts' -import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts' +import { countSubagentDescendants, isSideThreadSummary, rootAncestor } from './subagent-lineage.ts' -/** - * Side Chat threads ride the subagent origin (main-list hiding + the RPC - * ownership fence) but they are NOT subagent topology: they carry the - * durable 'Side: ' label and live as sidebar tabs. Excluding them here - * keeps the auto-open trigger and the Subagent page counts clean. - */ -export function isSideThreadSummary(summary: SidebarSessionSummary): boolean { - return summary.origin === 'subagent' && summary.displayTitle.startsWith(SIDE_LABEL_PREFIX) -} +export { countSubagentDescendants, isSideThreadSummary, rootAncestor } +export type { SubagentDescendantTotals } from './subagent-lineage.ts' /** Count the direct subagent children of one session (durable `origin` rows). */ export function directSubagentCount( @@ -40,28 +37,6 @@ export function directSubagentCount( return count } -/** - * The main agent of the current session's tree: walk the durable parent - * chain upward until the first non-subagent session. The Subagent page shows - * THIS root's full topology regardless of how deep the current selection is - * (a session whose row is still hydrating, or a broken chain, degrades to - * the session itself). - */ -export function rootAncestor( - byId: SidebarSessionList['byId'], - sessionId: string | undefined, -): string | undefined { - if (sessionId === undefined) return undefined - const seen = new Set() - let current: SidebarSessionSummary | undefined = byId[sessionId] - while (current !== undefined && current.origin === 'subagent' - && current.parentId !== undefined && !seen.has(current.id)) { - seen.add(current.id) - current = byId[current.parentId] - } - return current?.id ?? sessionId -} - /** * Collect every catalog branch (an entry with `hasChildren`) reachable from * the root — the set of catalogs the always-expanded topology consumes. @@ -101,37 +76,3 @@ export function detectNewDirectSubagent( return directSubagentCount(prev.byId, sessionId) === 0 && directSubagentCount(next.byId, sessionId) > 0 } - -/** Descendant totals of one session through an uninterrupted subagent-origin chain. */ -export interface SubagentDescendantTotals { - count: number - runningCount: number -} - -/** - * Index every subagent descendant under each ancestor it reaches through an - * uninterrupted subagent-origin chain (same semantics as the official - * `indexSubagentDescendants`; cycles fail soft). - */ -export function countSubagentDescendants( - byId: SidebarSessionList['byId'], - sessionId: string, -): SubagentDescendantTotals { - const totals: SubagentDescendantTotals = { count: 0, runningCount: 0 } - for (const descendant of Object.values(byId)) { - if (descendant.origin !== 'subagent' || isSideThreadSummary(descendant)) continue - const seen = new Set() - let current: SidebarSessionSummary | undefined = descendant - while (current?.origin === 'subagent' && current.parentId !== undefined - && !seen.has(current.id)) { - seen.add(current.id) - if (current.parentId === sessionId) { - totals.count += 1 - if (descendant.running === true) totals.runningCount += 1 - break - } - current = byId[current.parentId] - } - } - return totals -} diff --git a/src/client/subagent-jobs.ts b/src/client/subagent-jobs.ts index 4962392..76af4a6 100644 --- a/src/client/subagent-jobs.ts +++ b/src/client/subagent-jobs.ts @@ -8,11 +8,11 @@ */ import type { SidebarSessionList, - SidebarSessionSummary, SidebarJobStatus, SidebarJobView, } from '../context-types.ts' import type { CopyKey } from './locales.ts' +import { treeSessionIds } from './subagent-lineage.ts' /** One row of the jobs section: the job plus its owning session's title. */ export interface TreeJob { @@ -26,36 +26,10 @@ export function isJobLive(job: SidebarJobView): boolean { return job.status === 'running' || job.status === 'stopping' } -/** - * Every session id of the topology tree rooted at `rootId` (the root plus - * each session whose uninterrupted subagent-origin chain reaches it — same - * lineage semantics as {@link countSubagentDescendants}; cycles fail soft). - * Sessions outside the tree (orphans, other trees) are excluded, so the - * jobs section never shows foreign work. - */ -export function treeSessionIds( - byId: SidebarSessionList['byId'], - rootId: string | undefined, -): Set { - const ids = new Set() - if (rootId === undefined) return ids - for (const summary of Object.values(byId)) { - const seen = new Set() - let current: SidebarSessionSummary | undefined = summary - let reachesRoot = false - while (current !== undefined && !seen.has(current.id)) { - seen.add(current.id) - if (current.id === rootId) { - reachesRoot = true - break - } - if (current.origin !== 'subagent' || current.parentId === undefined) break - current = byId[current.parentId] - } - if (reachesRoot) ids.add(summary.id) - } - return ids -} +// The lineage walk itself lives in ./subagent-lineage.ts (the single shared +// subagent-origin chain implementation); re-exported for the established +// import sites of this module. +export { treeSessionIds } /** * Whether a NEW background job appeared for one session between two diff --git a/src/client/subagent-lineage.ts b/src/client/subagent-lineage.ts new file mode 100644 index 0000000..d4cce66 --- /dev/null +++ b/src/client/subagent-lineage.ts @@ -0,0 +1,131 @@ +/** + * The shared subagent-lineage walks over the sessions list feed (structural + * mirror world — no runtime imports). Every public walk here answers a + * topology question by following the DURABLE subagent-origin chain upward + * (child → parent → …); one private generator ({@link subagentOriginChain}) + * is the single implementation of that walk, seen-set guarded so cycles fail + * soft (the walk just stops; nothing throws). + * + * subagent-detect.ts and subagent-jobs.ts re-export these for their + * established import sites — the auto-activation triggers and the Subagent + * page both consume them through the original module paths. + */ +import type { SidebarSessionList, SidebarSessionSummary } from '../context-types.ts' +import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts' + +/** + * Side Chat threads ride the subagent origin (main-list hiding + the RPC + * ownership fence) but they are NOT subagent topology: they carry the + * durable 'Side: ' label and live as sidebar tabs. Excluding them here + * keeps the auto-open trigger and the Subagent page counts clean. + */ +export function isSideThreadSummary(summary: SidebarSessionSummary): boolean { + return summary.origin === 'subagent' && summary.displayTitle.startsWith(SIDE_LABEL_PREFIX) +} + +/** + * Yield `start`'s uninterrupted subagent-origin chain upward: each summary + * while it is a subagent with a known parent, then its parent row, and so + * on. A revisited id ends the walk (cycles fail soft); the row that BREAKS + * the chain — the first non-subagent ancestor, or a parent id with no row — + * is deliberately NOT yielded: callers observe it through where the walk + * stopped (the row after the last yielded one is `byId[last.parentId]`). + */ +function* subagentOriginChain( + byId: SidebarSessionList['byId'], + start: SidebarSessionSummary, +): Generator { + const seen = new Set() + let current: SidebarSessionSummary | undefined = start + while (current?.origin === 'subagent' && current.parentId !== undefined && !seen.has(current.id)) { + seen.add(current.id) + yield current + current = byId[current.parentId] + } +} + +/** + * The main agent of the current session's tree: walk the durable parent + * chain upward until the first non-subagent session. The Subagent page shows + * THIS root's full topology regardless of how deep the current selection is + * (a session whose row is still hydrating, or a broken chain, degrades to + * the session itself). + */ +export function rootAncestor( + byId: SidebarSessionList['byId'], + sessionId: string | undefined, +): string | undefined { + if (sessionId === undefined) return undefined + const start = byId[sessionId] + // A hydrating (or unknown) row degrades to the session itself. + if (start === undefined) return sessionId + let last: SidebarSessionSummary | undefined + for (const node of subagentOriginChain(byId, start)) last = node + if (last === undefined) return start.id + // The walk stopped one row past the last chain node: that parent row is + // the root (still missing from the feed → the session itself). Every + // yielded node carries a parent id (the generator's walk condition). + return byId[last.parentId!]?.id ?? sessionId +} + +/** Descendant totals of one session through an uninterrupted subagent-origin chain. */ +export interface SubagentDescendantTotals { + count: number + runningCount: number +} + +/** + * Index every subagent descendant under each ancestor it reaches through an + * uninterrupted subagent-origin chain (same semantics as the official + * `indexSubagentDescendants`; cycles fail soft). + */ +export function countSubagentDescendants( + byId: SidebarSessionList['byId'], + sessionId: string, +): SubagentDescendantTotals { + const totals: SubagentDescendantTotals = { count: 0, runningCount: 0 } + for (const descendant of Object.values(byId)) { + if (descendant.origin !== 'subagent' || isSideThreadSummary(descendant)) continue + for (const node of subagentOriginChain(byId, descendant)) { + if (node.parentId === sessionId) { + totals.count += 1 + if (descendant.running === true) totals.runningCount += 1 + break + } + } + } + return totals +} + +/** + * Every session id of the topology tree rooted at `rootId` (the root plus + * each session whose uninterrupted subagent-origin chain reaches it — same + * lineage semantics as {@link countSubagentDescendants}; cycles fail soft). + * Sessions outside the tree (orphans, other trees) are excluded, so the + * jobs section never shows foreign work. + */ +export function treeSessionIds( + byId: SidebarSessionList['byId'], + rootId: string | undefined, +): Set { + const ids = new Set() + // A root with no row in the feed cannot be reached by walking: only real + // rows anchor tree membership (a parent pointer naming a hydrating root + // does not pull the orphaned branch into the tree). + if (rootId === undefined || byId[rootId] === undefined) return ids + for (const summary of Object.values(byId)) { + // The root row itself belongs to its own tree even though it is not a + // subagent (it is usually the main agent). + if (summary.id === rootId) { + ids.add(summary.id) + continue + } + for (const node of subagentOriginChain(byId, summary)) { + if (node.parentId === rootId) { + ids.add(summary.id) + break + } + } + } + return ids +}