فهرست منبع

fix(web): keep known subagent chooser visible

Tianyi Cui 1 ماه پیش
والد
کامیت
711a33ea8d

+ 2 - 2
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
-2026-07-27-web-subagent-conversations.md: 34acb1410cf6316bca2980ed012046ffab9623f6
-2026-07-27-web-subagent-conversations.zh.md: 5dcd7025c5cd03fed34266834795de1f2b630648
+2026-07-27-web-subagent-conversations.md: b959fd35a4f5e2a6fa68deed8776ccbae86a0647
+2026-07-27-web-subagent-conversations.zh.md: dc0297d92acb5ab05dcdc5c682fd0a7fe2a2a18a

+ 3 - 3
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md

@@ -37,7 +37,7 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha
 
 ## Product contract
 
-The header action is absent only after a complete empty direct-catalog response. Its trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows.
+The header action is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. Its trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows.
 
 `running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority.
 
@@ -102,8 +102,8 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence
 - Host protocol tests pin schemas including required boolean expandability, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping.
 - Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence.
 - Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh.
-- jsdom tests pin the aggregate descendant count and activity, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons.
-- The keyless assembled Web snapshot contains an inactive continuable child, an inactive one-shot sibling, and a persisted grandchild; it pins the three-descendant trigger and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only.
+- jsdom tests pin the aggregate descendant count and activity, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons.
+- The keyless assembled Web snapshot contains an inactive continuable child, an inactive one-shot sibling, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only.
 - Navigation tests pin subagent-only breadcrumbs, workspace placement for forks created from subagents, and `origin: 'subagent'` sidebar filtering without hiding ordinary forks.
 
 ## Consequences

+ 3 - 3
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md

@@ -37,7 +37,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5
 
 ## 产品契约
 
-只有在完整的直接目录响应为空后,才不显示页头操作。其触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。
+只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示页头操作。其触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。
 
 `running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。
 
@@ -102,8 +102,8 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
 - 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。
 - 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。
 - 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。
-- jsdom 测试固定后代聚合计数与活动状态、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。
-- 无密钥的组装 Web 快照包含一个 inactive 的可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会固定触发器显示三个后代及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。
+- jsdom 测试固定后代聚合计数与活动状态、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。
+- 无密钥的组装 Web 快照包含一个 inactive 的可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。
 - 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。
 
 ## 后果

+ 3 - 0
apps/web/tests/snapshots/subagent-conversation/stale-catalog.expected.md

@@ -0,0 +1,3 @@
+- tree "Subagent sessions":
+  - treeitem "Loading subagents" [disabled] [level=1]: Loading subagents…
+  - treeitem "Loading subagents" [disabled] [level=1]: Loading subagents…

+ 54 - 0
apps/web/tests/subagent-conversation.e2e.ts

@@ -20,6 +20,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
 const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
 const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url))
 const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/tree.expected.md', import.meta.url))
+const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/stale-catalog.expected.md', import.meta.url))
 const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url))
 const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/nested.expected.md', import.meta.url))
 const FORK_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/fork.expected.md', import.meta.url))
@@ -239,6 +240,59 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     if (failures.length > 1) throw new AggregateError(failures, 'subagent Web teardown failed')
   })
 
+  it('keeps known descendants reachable across a stale empty catalog response', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-stale-catalog'))
+    const pattern = '**/api/subagent.list'
+    let firstClaimed = false
+    let emptyDelivered = false
+    let trailingRequested = false
+    let releaseCatalog = (): void => {}
+    const catalogHeld = new Promise<void>((resolve) => { releaseCatalog = resolve })
+    await page.route(pattern, async (route) => {
+      if (firstClaimed) {
+        const response = await route.fetch()
+        trailingRequested = true
+        await catalogHeld
+        await route.fulfill({ response })
+        return
+      }
+      firstClaimed = true
+      const response = await route.fetch()
+      const body = await response.json() as {
+        result: { ok: true; value: { entries: unknown[] } } | { ok: false }
+      }
+      if (body.result.ok) body.result.value.entries = []
+      await route.fulfill({ response, json: body })
+      emptyDelivered = true
+    })
+
+    const warningStart = tripwire.warnings.length
+    try {
+      await page.reload({ waitUntil: 'load' })
+      await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+      await expect.poll(() => emptyDelivered, { timeout: 15_000 }).toBe(true)
+      await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 })
+      acknowledgeReloadConnectionLoss(tripwire, warningStart)
+
+      await page.getByRole('button', { name: '3 subagents' }).click()
+      await expect.poll(() => trailingRequested, { timeout: 15_000 }).toBe(true)
+      const tree = page.getByRole('tree', { name: 'Subagent sessions' })
+      await tree.getByRole('treeitem', { name: 'Loading subagents' }).first().waitFor()
+      expect(await tree.getByRole('treeitem', { name: 'Loading subagents' }).count()).toBe(2)
+      await compareOrRefreshGolden(
+        STALE_CATALOG_EXPECTED,
+        await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd),
+        MODE,
+      )
+      releaseCatalog()
+      await tree.getByRole('treeitem', { name: new RegExp(LABEL) }).waitFor({ timeout: 15_000 })
+      await tree.press('Escape')
+    } finally {
+      releaseCatalog()
+      await page.unroute(pattern)
+    }
+  })
+
   it('expands a persisted grandchild progressively without activating either level', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree'))
     await page.getByRole('button', { name: '3 subagents' }).click()

+ 16 - 3
packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx

@@ -304,7 +304,7 @@ function CatalogRows({
 /**
  * Render the current session's direct catalog and lazily expanded descendants.
  * @param props - session standard props plus catalog navigation actions.
- * @returns The action only after a non-empty catalog arrives.
+ * @returns The action while the catalog is pending or summaries establish descendants.
  */
 export function SubagentCatalogAction({
   sessionId, useSessions, openChild, refresh, setCatalogOpen, t,
@@ -326,6 +326,18 @@ export function SubagentCatalogAction({
   const descendantCount = Math.max(healthy.length, descendants.count)
   const totalCountKey = descendantCount === 1 ? 'count.total.one' : 'count.total.other'
   const runningCountKey = descendantCount === 1 ? 'count.running.one' : 'count.running.other'
+  // Session summaries can announce membership before the descriptor-backed catalog catches up.
+  // Keep that entry point visible through disabled loading rows; only catalog rows are navigable.
+  const summaryBackedLoading = descendants.count > 0
+    && (catalog === undefined || (catalog.state === 'ready' && catalog.entries.length === 0))
+  const presentedCatalog: SubagentCatalogSnapshot | undefined = summaryBackedLoading
+    ? {
+      entries: [],
+      parentAvailable: catalog?.parentAvailable ?? false,
+      state: 'loading',
+      error: null,
+    }
+    : catalog
 
   const observeCatalog = (parentSessionId: SessionId, next: boolean): void => {
     if (next) observedCatalogs.current.add(parentSessionId)
@@ -390,7 +402,8 @@ export function SubagentCatalogAction({
     observedCatalogs.current.clear()
   }, [])
 
-  const visible = catalog !== undefined && (catalog.state !== 'ready' || catalog.entries.length > 0)
+  const visible = presentedCatalog !== undefined
+    && (presentedCatalog.state !== 'ready' || presentedCatalog.entries.length > 0)
   useEffect(() => {
     if (visible || !open) return
     setOpen(false)
@@ -453,7 +466,7 @@ export function SubagentCatalogAction({
         <div className={css.menu} role="tree" aria-label={t('tree.aria')}>
           <CatalogRows
             parentSessionId={sessionId}
-            catalog={catalog}
+            catalog={presentedCatalog}
             catalogs={catalogs}
             summaries={summaries}
             expanded={expanded}

+ 26 - 0
packages/client/ui-subagent/tests/conversation-ui.spec.tsx

@@ -423,6 +423,32 @@ describe('SubagentCatalogAction', () => {
     expect(failed.refresh).toHaveBeenCalledWith(PARENT)
   })
 
+  it('keeps known descendants reachable while their catalog is absent or stale-empty', () => {
+    const second = 'child-2' as SessionId
+    const summaries = {
+      [CHILD]: {
+        ...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const,
+      },
+      [second]: {
+        ...summary(second, 1), parentId: PARENT, origin: 'subagent' as const, running: true,
+      },
+    }
+    const absent = props(undefined, {}, summaries)
+    const view = render(<SubagentCatalogAction {...absent} />)
+
+    const trigger = screen.getByRole('button', { name: '2 个子代理,正在运行' })
+    fireEvent.click(trigger)
+    expect(absent.setCatalogOpen).toHaveBeenCalledWith(PARENT, true)
+    expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2)
+    expect(absent.openChild).not.toHaveBeenCalled()
+
+    const staleEmpty = props(catalog({ entries: [] }), {}, summaries)
+    view.rerender(<SubagentCatalogAction {...staleEmpty} />)
+    expect(screen.getByRole('button', { name: '2 个子代理,正在运行' })).toBeTruthy()
+    expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2)
+    expect(staleEmpty.openChild).not.toHaveBeenCalled()
+  })
+
   it('renders empty loading and fallback error states without focusable rows', async () => {
     const loading = props(catalog({ entries: [], state: 'loading' }))
     const view = render(<SubagentCatalogAction {...loading} />)