Files
apes-sidebar/src/client/subagent-detect.ts
T
Atlas Zeng 7f4f84c4cf feat(sidechat): 侧边对话——每对话一 Tab(Codex 对齐)+ 完整上下文继承 + 保存为新会话 (#286)
三轮实测驱动迭代:种子信封透传(surfaceOp 校验)、每线程一 Tab 交互重做完、四连修(fork 丢 this / 自绘 history+save 图标 / collectOwnEvents 大页回源修重开丢工具行 / descriptor 入种子修目录 corrupt 行 + 前缀过滤保拓扑零噪音)。739 单测 + 挂载冒烟 12/12 全绿。
2026-08-21 15:41:14 +08:00

138 lines
4.8 KiB
TypeScript

/**
* Pure subagent-membership helpers over the sessions list feed (structural
* mirror world — no runtime imports). Used by the sidebar's auto-activation
* effect and the Subagent page:
*
* - {@link directSubagentCount}: direct durable children of one session,
* - {@link detectNewDirectSubagent}: the 0 → N transition that means "a new
* subagent just spawned under the current session" (the auto-open trigger),
* - {@link countSubagentDescendants}: uninterrupted subagent-origin lineage
* totals (mirror of the official `indexSubagentDescendants` over the
* plugin's own summary rows).
*/
import type {
SidebarSessionList,
SidebarSessionSummary,
SidebarSubagentCatalog,
} 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)
}
/** Count the direct subagent children of one session (durable `origin` rows). */
export function directSubagentCount(
byId: SidebarSessionList['byId'],
sessionId: string,
): number {
let count = 0
for (const summary of Object.values(byId)) {
if (summary.origin === 'subagent' && summary.parentId === sessionId
&& !isSideThreadSummary(summary)) count += 1
}
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<string>()
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.
* Cycles fail soft.
*/
export function collectBranchIds(
catalogs: Readonly<Record<string, SidebarSubagentCatalog>>,
rootId: string | undefined,
): string[] {
const out: string[] = []
const seen = new Set<string>()
const visit = (parentId: string): void => {
if (seen.has(parentId)) return
seen.add(parentId)
for (const entry of catalogs[parentId]?.entries ?? []) {
if (entry.kind === 'child' && entry.hasChildren) {
out.push(entry.id)
visit(entry.id)
}
}
}
if (rootId !== undefined) visit(rootId)
return out
}
/**
* Whether a new direct subagent appeared under `sessionId` between two
* consecutive list snapshots (the count crossed 0 → >0). Switching to a
* session that already has subagents yields `false` (its baseline starts at
* the current count), so the auto-open never fights an existing layout.
*/
export function detectNewDirectSubagent(
prev: SidebarSessionList,
next: SidebarSessionList,
sessionId: string,
): boolean {
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<string>()
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
}