Explorar el Código

feat(workflow): let users control run and phase disclosures

pku-xht hace 2 meses
padre
commit
941e5c5061

+ 2 - 2
.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.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-08-11-workflow-run-status-driven-disclosure.md
-2026-08-11-workflow-run-status-driven-disclosure.md: 2f452d25a8922bb6c275419af55e8af155dd2781
-2026-08-11-workflow-run-status-driven-disclosure.zh.md: 12cc106fea274a1681ee5615906ae6df266d567b
+2026-08-11-workflow-run-status-driven-disclosure.md: a783ede82442cfb28ed4c0a2fd394677728949d8
+2026-08-11-workflow-run-status-driven-disclosure.zh.md: 0d047bf7383f68ff7ec23d3602789e776c85d2cc

+ 16 - 14
.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md

@@ -6,38 +6,40 @@ English | [中文](2026-08-11-workflow-run-status-driven-disclosure.zh.md)
 
 ## Problem
 
-A durable workflow Chat node updates in place from its running prefix to a terminal record. A disclosure choice initialized only at mount can hide a newly running phase, leave completed work occupying the conversation, or bury a failed, cancelled, or interrupted member behind two collapsed levels. Making openness a pure function of completion avoids those failures but also prevents users from reopening clean history for review.
+A durable workflow Chat node updates in place from its running prefix to a terminal record. The renderer must draw attention to new work, abnormal outcomes, and normal completion without repeatedly overriding a user's decision to reclaim conversation space.
 
-The renderer already receives every required lifecycle fact from the workflow Conversation Node. Visibility therefore needs a component-local lifecycle that gives current execution and attention states priority without adding another durable fact or taking ownership of workflow outcomes.
+The renderer already receives every durable lifecycle fact from the workflow Conversation Node. Disclosure choice therefore belongs to the mounted presentation, but its lifecycle must also preserve nested phase choices when the outer run is hidden and avoid removing content that still contains keyboard focus.
 
 ## Decision
 
-Each phase derives one visibility requirement from its current members. A running, failed, cancelled, or interrupted member forces that phase open; a phase whose members are all completed is clean. The workflow forces itself open when its own status requires attention or any phase is forced open, so an abnormal member remains visible even when the workflow outcome is recorded as completed. A completed sibling phase remains independently collapsible.
+`WorkflowRunPanel` owns one local disclosure state for the run and a map keyed by the existing phase key. A phase is clean when every member completed, abnormal when any member failed, was cancelled, or was interrupted, and running otherwise. The run is abnormal when its own status or any phase is abnormal, running when its own status or any phase is running, and clean only when the run and every phase completed normally. A mount opens running and abnormal levels and closes clean levels.
 
-A forced-open level renders as an expanded static row. It exposes no button role, focus target, keyboard toggle, or `aria-expanded` value because collapsing cannot change the result. This keeps the visual hierarchy and status summaries while making the interaction promise match the available action.
+Each level records its current mode, append-only member count, open choice, and any pending clean close. Ordinary updates within a running or abnormal interval preserve the user's choice. A phase transition from clean to activity opens that phase and the outer run once, the first transition into abnormal opens once, and a transition into clean closes once. A member-count change while a phase remains clean represents a complete activity cycle delivered in one render and closes an open review without adding an activity epoch or durable field. After an automatic action, mouse, Enter, and Space control the level until another defined edge occurs.
 
-A clean level mounts an ordinary controlled disclosure in the closed state. Its local choice survives rerenders for the same continuous clean interval. New running or abnormal data replaces that manual interval with forced expansion; the next transition back to clean mounts a fresh closed disclosure, which produces one automatic fold per activity cycle. Closing the workflow naturally unmounts its phase controls, and a Session remount reconstructs every level from the current durable status rather than restoring an earlier choice.
+Phase state remains in `WorkflowRunPanel` while the outer disclosure hides its children, so closing and reopening the run restores each phase choice. Removing a phase deletes its entry; a renderer remount reconstructs every level from current durable facts rather than restoring an earlier choice.
 
-For example, a running workflow exposes its active phase and member without clicks. When that phase completes, only the phase folds while the workflow remains open; when the workflow and every phase complete, the workflow also folds. The user can then reopen both levels for review. If another member starts under the same phase key, both affected levels immediately return to forced expansion and fold again only after the new activity completes.
+Normal completion checks whether focus is inside the content before closing. Focused content remains mounted with current completed status and closes after focus leaves. When a navigable member becomes terminal while its button holds focus, `MemberRow` keeps the same button mounted as `aria-disabled` until blur; later terminal review renders the ordinary non-interactive row. This preserves the active DOM target without allowing terminal navigation or adding a focus manager.
 
-The renderer owns only this visibility lifecycle. It does not add Session events, stores, settings, acknowledgement state, timers, focus movement, automatic scrolling, or cross-remount persistence. It does not change workflow status derivation, phase grouping, member order, navigation eligibility, copy, or the shared `DisclosureRow` API. Shared `data-expandable` styling owns pointer cursors, so forced-open static rows do not advertise an unavailable action. An interrupted durable prefix remains an attention state and therefore stays visible until the underlying facts change.
+The renderer adds no Session events, store, setting, acknowledgement, timer, automatic scrolling, persistent activity identity, or `DisclosureRow` API. It does not change workflow status derivation, phase grouping, member order, navigation eligibility, copy, or visual tokens.
 
 ## Verification
 
-Component tests drive the same keyed workflow and phase through running, clean completion, manual review, renewed activity, repeated clean completion, zero-member completion, and each abnormal status. They also verify abnormal-member propagation, clean-sibling independence, mouse and keyboard review, continuous-clean choice retention, and the absence of false button and ARIA semantics while expansion is mandatory.
+Component tests drive one keyed run and its phases through initial running controls, mouse and keyboard choices, ordinary running updates, outer hide and restore, phase completion, run completion, clean review, same-key renewed activity, a fully batched clean cycle, every abnormal status, first-abnormal escalation, later abnormal updates, zero-member completion, focused-member completion, sibling independence, and renderer remount. They also verify terminal navigation remains absent after the deferred focus path settles.
 
-The shipped Web replay observes the real workflow, worker, Session log, browser plugin graph, and child navigation. It requires the live workflow and active phase to be visible without disclosure controls, the normally settled workflow and phase to fold, manual review to retain the terminal member without navigation, and a reload to reconstruct the folded history from durable facts.
+The shipped Web replay exercises the real workflow, worker, Session log, browser plugin graph, and child navigation. It collapses and reopens live run and phase controls, records the live collapsed status summary and ARIA state, verifies normal settlement folds both levels, confirms terminal review cannot navigate the member, and records the folded history reconstructed after reload.
 
 ## Alternatives considered
 
-**Keep one manual state initialized from the first render.** Rejected because later lifecycle updates cannot reopen newly active or abnormal content and cannot fold normally settled work.
+**Force every running or abnormal level open as a static row.** Rejected because it makes the attention state impossible to dismiss and removes truthful mouse, keyboard, and ARIA disclosure semantics.
 
-**Derive `open` directly from whether a level is clean.** Rejected because completed history would remain permanently closed and could not be reopened for review.
+**Keep one manual state initialized from the first render.** Rejected because later activity, abnormal escalation, and normal completion cannot perform their one-time automatic actions.
 
-**Persist expansion, acknowledgement, or read state.** Rejected because current lifecycle facts already determine mandatory visibility, while review choice belongs only to the mounted presentation. Persistence would add a second state owner and require semantics for stale choices, abnormal acknowledgement, replay, and synchronization that the user result does not need.
+**Let each phase own state inside its disclosure content.** Rejected because hiding the outer run unmounts that content and discards independent phase choices during the same mounted workflow record.
+
+**Persist expansion, acknowledgement, or an activity epoch.** Rejected because current workflow facts and the append-only member count provide every required edge. Persistence adds a second durable owner and synchronization semantics that this presentation choice does not need.
 
 ## Consequences
 
-Workflow records expose current work and abnormal outcomes without preparatory clicks, then reclaim conversation space after normal completion without sacrificing review. Interaction semantics remain truthful during automatic control, and the same durable record produces the same initial state during live rendering, refresh, and history reconstruction.
+Workflow records call attention to lifecycle changes while remaining dismissible in every status. Normal completion reclaims space, current focus remains safe, nested phase choices survive outer hiding, and the same durable record reconstructs a deterministic initial state on refresh or history replay.
 
-The trade-off is deliberate local reset behavior. A phase choice disappears when its parent workflow closes or the component unmounts, and abnormal records cannot be manually hidden because the product has no acknowledgement state. Supporting either behavior later requires a separate ownership and persistence decision rather than extending this local lifecycle implicitly.
+The local lifecycle deliberately resets on renderer remount and cannot remember a choice across refresh, devices, or users. Adding that behavior requires a separate persistence and stale-choice decision rather than extending this presentation state implicitly.

+ 16 - 14
.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md

@@ -6,38 +6,40 @@ Status: implemented
 
 ## 问题
 
-持久工作流 Chat 节点会在同一位置从运行前缀更新为终态记录。只在挂载时初始化的 disclosure 选择可能隐藏新开始运行的阶段,让已完成工作继续占据对话空间,或者把失败、已取消或已中断成员埋在两层折叠内容之后。若只把开合状态作为完成状态的纯派生结果,虽然能避免这些问题,却也会阻止用户重新打开干净历史进行复盘。
+持久工作流 Chat 节点会在同一位置从运行前缀更新为终态记录。renderer 必须提示新工作、异常结果和正常完成,同时不能在普通更新中反复覆盖用户回收对话空间的选择。
 
-renderer 已经从工作流 Conversation Node 收到全部所需生命周期事实。因此,可见性需要一个组件本地生命周期:让当前执行与需注意状态优先,同时不增加另一项持久事实,也不取得工作流结果的所有权。
+renderer 已经从工作流 Conversation Node 收到全部持久生命周期事实。因此,disclosure 选择属于已挂载的展示层,但它的生命周期还必须在外层运行隐藏时保留嵌套 Phase 选择,并避免移除仍含键盘焦点的内容。
 
 ## 决策
 
-每个阶段从当前成员派生一项可见性要求。存在运行中、失败、已取消或已中断成员时,该阶段强制展开;全部成员均已完成时,该阶段处于干净状态。工作流自身状态需要注意或任一阶段强制展开时,工作流也强制展开,因此即使工作流结果记录为已完成,异常成员仍保持可见。已完成的兄弟阶段继续可以独立折叠。
+`WorkflowRunPanel` 持有一项运行 disclosure 本地状态,以及一张按现有 phase key 索引的 Phase 状态表。全部成员都已完成时,Phase 为干净状态;任一成员失败、已取消或已中断时为异常状态;其余情况为运行状态。运行自身或任一 Phase 异常时,运行处于异常状态;运行自身或任一 Phase 正在运行时,运行处于运行状态;只有运行与全部 Phase 都正常完成时才处于干净状态。挂载时,运行和异常层级默认展开,干净层级默认折叠。
 
-强制展开层级渲染为静态展开行。它不提供按钮 role、焦点目标、键盘切换或 `aria-expanded` 值,因为折叠操作无法改变结果。这样既保留视觉层级与状态摘要,也让交互承诺与实际可执行动作一致。
+每个层级记录当前模式、仅追加成员数、开合选择和待执行的干净折叠。Phase 从干净状态进入新活动时,该 Phase 与外层运行自动展开一次;连续运行或异常区间内的普通更新保留用户选择,首次进入异常状态时自动展开一次,进入干净状态时自动折叠一次。若新增成员及其正常完成在同一次渲染中送达,Phase 会保持干净但成员数改变;该变化会折叠已打开的复盘,而无需增加 activity epoch 或持久字段。自动动作完成后,鼠标、Enter 和 Space 控制该层级,直到出现下一项约定边沿。
 
-干净层级会以关闭状态挂载普通受控 disclosure。它的本地选择在同一段连续干净状态的 rerender 中保持。新的运行中或异常数据会用强制展开替代该手动区间;下一次回到干净状态时会挂载新的关闭 disclosure,从而让每个活动周期只自动折叠一次。关闭工作流会自然卸载其阶段控件;Session remount 会从当前持久状态重建每个层级,而不恢复更早的选择。
+外层 disclosure 隐藏子内容时,Phase 状态仍留在 `WorkflowRunPanel`,因此关闭并重新打开运行会恢复各 Phase 选择。Phase 被移除时,其表项同时清理;renderer remount 会从当前持久事实重建每个层级,而不恢复更早选择。
 
-例如,运行中的工作流无需点击即可展示活跃阶段与成员。该阶段完成时,只有阶段折叠,工作流继续展开;工作流自身和全部阶段均完成时,工作流也会折叠。用户随后可以重新打开两个层级复盘。若同一阶段 key 下又开始新成员,受影响的两个层级会立即恢复强制展开,并且只在新活动完成后再次折叠。
+正常完成会在折叠前检查焦点是否位于内容内。仍含焦点的内容保持挂载并立即显示完成状态,焦点离开后再折叠。可导航成员的按钮持有焦点并变为终态时,`MemberRow` 会把同一个按钮以 `aria-disabled` 形式保留到 blur;之后的终态复盘渲染普通不可交互行。这样既保留当前 DOM 目标,也不允许终态导航,并且无需增加焦点管理器。
 
-renderer 只拥有这项可见性生命周期。它不增加 Session 事件、store、设置、确认状态、计时器、焦点迁移、自动滚动或跨 remount 持久化。它不改变工作流状态派生、阶段分组、成员顺序、导航准入、文案或共享 `DisclosureRow` API。pointer 光标由共享的 `data-expandable` 样式拥有,因此强制展开的静态行不会提示无法执行的操作。持久记录中的中断前缀仍属于需注意状态,因此在底层事实改变前始终可见。
+renderer 不增加 Session 事件、store、设置、确认状态、计时器、自动滚动、持久活动身份或 `DisclosureRow` API。它不改变工作流状态派生、Phase 分组、成员顺序、导航准入、文案或视觉 token。
 
 ## 验证
 
-组件测试驱动同一个 keyed 工作流与阶段依次经过运行、干净完成、手动复盘、新活动、再次干净完成、零成员完成以及每种异常状态。测试还验证异常成员向上展开、干净兄弟阶段独立、鼠标和键盘复盘、连续干净状态中的选择保持,以及强制展开时不存在虚假按钮和 ARIA 语义。
+组件测试驱动同一个 keyed 运行及其 Phase,覆盖初始运行控件、鼠标和键盘选择、普通运行更新、外层隐藏与恢复、Phase 完成、运行完成、干净复盘、同 key 新活动、同次渲染送达的完整干净周期、每种异常状态、首次异常升级、后续异常更新、零成员完成、成员持焦点时完成、兄弟 Phase 独立以及 renderer remount。测试还确认延后焦点路径结算后,终态导航仍不存在。
 
-shipped Web 回放观察真实工作流、worker、Session 日志、浏览器插件图和子级导航。它要求实时工作流与活跃阶段无需 disclosure 控件即可见,正常结算的工作流与阶段会折叠,手动复盘仍能看到不再可导航的终态成员,并且刷新会从持久事实重建折叠历史。
+shipped Web 回放经过真实工作流、worker、Session 日志、浏览器插件图和子级导航。它折叠并重新打开实时运行与 Phase 控件,记录实时折叠标题的状态摘要和 ARIA 状态,验证正常结算会折叠两个层级,确认终态复盘不能导航成员,并记录刷新后从历史重建的折叠记录。
 
 ## 曾考虑的替代方案
 
-**保留一项从首次渲染初始化的手动状态。** 拒绝,因为后续生命周期更新无法重新打开新活动或异常内容,也无法折叠正常结算的工作。
+**把每个运行中或异常层级强制展开为静态行。** 拒绝,因为需注意状态将无法收起,也不会提供真实的鼠标、键盘和 ARIA disclosure 语义。
 
-**只根据层级是否干净来派生 `open`。** 拒绝,因为已完成历史会永久保持关闭,无法重新打开复盘。
+**保留一项从首次渲染初始化的手动状态。** 拒绝,因为后续活动、异常升级和正常完成无法执行各自的一次性自动动作。
 
-**持久化展开、确认或已读状态。** 拒绝,因为当前生命周期事实已经决定强制可见性,而复盘选择只属于已挂载的展示层。持久化会增加第二个状态归属方,并要求定义陈旧选择、异常确认、回放和同步语义,而用户结果不需要这些机制。
+**让每个 Phase 在自身 disclosure 内容中持有状态。** 拒绝,因为隐藏外层运行会卸载这些内容,并在同一条已挂载工作流记录中丢失独立的 Phase 选择。
+
+**持久化展开、确认或 activity epoch。** 拒绝,因为当前工作流事实与仅追加成员数已经提供全部所需边沿。持久化会增加第二个持久归属方以及本展示选择不需要的同步语义。
 
 ## 后果
 
-工作流记录无需预备点击即可展示当前工作与异常结果,并在正常完成后回收对话空间,同时不牺牲复盘能力。自动控制期间的交互语义保持真实,同一份持久记录在实时渲染、刷新和历史重建时得到相同初始状态。
+工作流记录会提示生命周期变化,同时在所有状态下都允许用户收起。正常完成会回收空间,当前焦点保持安全,嵌套 Phase 选择在外层隐藏期间保留;同一份持久记录在刷新或历史回放时会重建确定性的初始状态。
 
-代价是有意保留的本地重置行为。父工作流关闭或组件卸载时,阶段选择会消失;由于产品没有确认状态,异常记录不能手动隐藏。以后若要支持任一行为,需要单独决定所有权与持久化,而不能隐式扩展这项本地生命周期。
+这项本地生命周期会在 renderer remount 时重置,无法跨刷新、设备或用户记住选择。若要增加该行为,需要单独决定持久化与陈旧选择语义,而不能隐式扩展这项展示状态。

+ 23 - 0
apps/web/tests/snapshots/workflow-run/ui-live.expected.md

@@ -0,0 +1,23 @@
+- text: "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): phase('Run') const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') return { reply } After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool. {{clock}}"
+- button "Copy":
+  - img
+- button "Context injection @deepseek-ai/dsh-system-prompt":
+  - img
+  - img
+  - text: Context injection @deepseek-ai/dsh-system-prompt
+- button "Think The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:":
+  - img
+  - img
+  - text: "Think The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:"
+- text: Running
+- button "Tool call workflow ·":
+  - img
+  - img
+  - text: Tool call workflow ·
+- button "snapshot-flow 1 member Running" [expanded]:
+  - img
+  - text: snapshot-flow 1 member Running
+- button "Run 1 member Running 1":
+  - img
+  - text: Run 1 member Running 1
+- status: Deep diving...

+ 1 - 5
apps/web/tests/snapshots/workflow-run/ui.expected.md

@@ -13,13 +13,9 @@
   - img
   - img
   - text: Tool call workflow ·
-- button "snapshot-flow 1 member Completed" [expanded]:
+- button "snapshot-flow 1 member Completed":
   - img
   - text: snapshot-flow 1 member Completed
-- button "Run 1 member Completed 1" [expanded]:
-  - img
-  - text: Run 1 member Completed 1
-- text: Reply with exactly the word WF_CHILD_OK and not… Completed
 - button "Think The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop.":
   - img
   - img

+ 27 - 10
apps/web/tests/workflow-run.e2e.ts

@@ -20,6 +20,7 @@ import {
 
 const MODE = webSnapshotMode()
 const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workflow-run', import.meta.url))
+const UI_LIVE_EXPECTED = join(SNAPSHOT_DIR, 'ui-live.expected.md')
 const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
 const PARENT_FIXTURE = join(REPO_ROOT, 'examples/acp-agent/tests/snapshots/workflow-run/session.jsonl')
 const CHILD_FIXTURE = join(REPO_ROOT, 'examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl')
@@ -52,7 +53,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
     scaffold = await launchWebScaffold({
       replayFixture: PARENT_FIXTURE,
       replayChildFixtures: [CHILD_FIXTURE],
-      paceMs: 25,
+      paceMs: 50,
     })
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
@@ -78,14 +79,30 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
     await workflow.waitFor({ timeout: 30_000 })
     const disclosures = workflow.locator('[data-disclosure-row]')
     await disclosures.nth(1).waitFor({ timeout: 15_000 })
-    expect(await disclosures.nth(0).getAttribute('role')).toBeNull()
-    expect(await disclosures.nth(0).getAttribute('aria-expanded')).toBeNull()
-    expect(await disclosures.nth(1).getAttribute('role')).toBeNull()
-    expect(await disclosures.nth(1).getAttribute('aria-expanded')).toBeNull()
-    expect(await disclosures.nth(0).evaluate(element => getComputedStyle(element).cursor)).not.toBe('pointer')
-    expect(await disclosures.nth(1).evaluate(element => getComputedStyle(element).cursor)).not.toBe('pointer')
+    const runDisclosure = disclosures.nth(0)
+    const phaseDisclosure = disclosures.nth(1)
+    expect(await runDisclosure.getAttribute('role')).toBe('button')
+    expect(await runDisclosure.getAttribute('aria-expanded')).toBe('true')
+    expect(await phaseDisclosure.getAttribute('role')).toBe('button')
+    expect(await phaseDisclosure.getAttribute('aria-expanded')).toBe('true')
+    expect(await runDisclosure.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer')
+    expect(await phaseDisclosure.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer')
     const member = page.getByRole('button', { name: /^Open Reply with exactly the word/ })
     await member.waitFor({ timeout: 15_000 })
+
+    await phaseDisclosure.click()
+    expect(await phaseDisclosure.getAttribute('aria-expanded')).toBe('false')
+    expect(await member.count()).toBe(0)
+    const liveSnapshot = await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(UI_LIVE_EXPECTED, liveSnapshot, MODE)
+    await phaseDisclosure.press('Enter')
+    await member.waitFor()
+    await runDisclosure.click()
+    expect(await runDisclosure.getAttribute('aria-expanded')).toBe('false')
+    expect(await disclosures.count()).toBe(1)
+    await runDisclosure.press('Space')
+    expect(await disclosures.count()).toBe(2)
+    expect(await phaseDisclosure.getAttribute('aria-expanded')).toBe('true')
     await member.focus()
 
     const lightColor = await member.locator('[data-member-label]').evaluate(element => getComputedStyle(element).color)
@@ -171,6 +188,8 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
     const workflow = page.getByRole('button', { name: /^snapshot-flow/ })
     await workflow.waitFor({ timeout: 15_000 })
     expect(await workflow.getAttribute('aria-expanded')).toBe('false')
+    const snapshot = await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
     await workflow.click()
     const phase = page.getByRole('button', { name: /^Run/ })
     await phase.waitFor()
@@ -179,13 +198,11 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
     await page.getByText(CHILD_PROMPT, { exact: false }).waitFor()
     expect(await page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count()).toBe(0)
 
-    const snapshot = await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd)
-    await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
   }, 60_000)
 
   it('stays clean and owns only its one golden', async () => {
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
-    await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['ui-live.expected.md', 'ui.expected.md'])
   })
 })

+ 2 - 2
packages/client/ui-workflow-run/README.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 packages/client/ui-workflow-run/README.md
-README.md: 3fcdc632b54be7d1f8f452c6f8b3b2e1525953aa
-README.zh.md: 5f0ba0c8f8a251d0ce5977d469ee60637f0454db
+README.md: 837ae237258430d942ebdd28a0f4d6efd2876de8
+README.zh.md: dfec61005f8f06637602722041f3233219b7216b

+ 1 - 1
packages/client/ui-workflow-run/README.md

@@ -12,7 +12,7 @@ Phase groups come only from members that actually started. Exact phase strings s
 
 ## Presentation and navigation
 
-The run and each phase derive disclosure control from their current lifecycle facts. The run stays expanded while its own status is running, failed, cancelled, or interrupted, or while any phase contains such a member; each affected phase also stays expanded. Forced-open headers are static expanded rows without button, keyboard, or `aria-expanded` promises. A phase folds once when every member completes, and the run folds once when it and every phase complete. Each clean layer then exposes an ordinary disclosure control whose local choice survives clean rerenders; new activity takes control again, and a remount derives the initial state from current data. The run uses a 32-pixel `--dsw-alias-bg-module-platform` row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column.
+The run and each phase are controlled disclosures in every status. A mount opens running, failed, cancelled, and interrupted levels and closes fully completed levels; users can then toggle either level with the full row, Enter, or Space. Ordinary running updates preserve the current choice, the first abnormal edge opens once, normal completion closes once, and a completed phase plus the outer run open again when a new running member starts under the same phase key. Completion updates the visible status immediately but delays its automatic close while focus remains inside the content. `WorkflowRunPanel` owns the phase choices, so closing and reopening the outer run does not reset them; a renderer remount reconstructs every initial choice from durable facts. The run uses a 32-pixel `--dsw-alias-bg-module-platform` row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column.
 
 A member opens a child Session only while every current fact agrees: the member is running, the child id is in the ordinary Session list, the row has `origin: 'subagent'`, its `parentId` is the current Session, and the list row is still running. Underlined member text is the only visible navigation affordance; keyboard focus draws a two-pixel business-primary ring around the name area, while status copy remains `Running`. The component calls only the injected ordinary `sessions.open(id)` action; remote, addressed-only, wrong-parent, or terminal rows remain non-interactive.
 

+ 1 - 1
packages/client/ui-workflow-run/README.zh.md

@@ -12,7 +12,7 @@
 
 ## 展示与导航
 
-运行和每个阶段都从当前生命周期事实派生 disclosure 控制。运行自身处于运行中、失败、已取消或已中断,或者任一阶段包含这些状态的成员时,运行保持展开;受影响的阶段也保持展开。强制展开的标题行只是静态展开行,不承诺按钮、键盘操作或 `aria-expanded`。阶段在全部成员完成时折叠一次;运行在自身和全部阶段都完成时折叠一次。每个干净层级随后恢复普通 disclosure 控件,其本地选择在干净状态的 rerender 中保持;新活动会重新取得控制,remount 则从当前数据派生初始状态。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。
+运行和每个阶段在所有状态下都是受控 disclosure。挂载时,运行中、失败、已取消和已中断层级默认展开,全部完成的层级默认折叠;此后用户可以点击整行,或按 Enter、Space 切换任一层级。普通运行更新保留当前选择,首次异常边沿只自动展开一次,正常完成只自动折叠一次;已完成阶段在同一 phase key 下开始新的运行成员时,该 Phase 与外层运行会再次自动展开。完成状态会立即更新,但只要焦点仍位于展开内容内,自动折叠就会等待焦点离开。Phase 选择由 `WorkflowRunPanel` 持有,因此关闭并重新打开外层运行不会重置它们;renderer remount 会从持久事实重建每层的初始选择。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。
 
 只有所有实时事实同时成立时,成员才可打开子 Session:成员仍在运行、子 id 位于普通 Session 列表、列表行为 `origin: 'subagent'`、`parentId` 等于当前 Session,且列表行仍标记运行。带下划线的成员文字是唯一可见导航提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环,右侧状态仍只显示“运行中”。组件只调用注入的普通 `sessions.open(id)`;远程、仅地址化、父级不符或终态的行都不可交互。
 

+ 223 - 48
packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx

@@ -1,4 +1,7 @@
-import { useState, type ReactNode } from 'react'
+import {
+  useLayoutEffect, useMemo, useRef, useState,
+  type FocusEvent, type ReactNode,
+} from 'react'
 import {
   DisclosureRow, IconChevronRightOutline14, StateDot,
   type DisclosureRowProps, type StateDotState,
@@ -63,34 +66,85 @@ function memberCount(count: number, t: WorkflowRunPanelProps['t']): string {
   return t(count === 1 ? 'run.members.one' : 'run.members.other', { count })
 }
 
-function phaseRequiresExpansion(phase: WorkflowRunPhaseData): boolean {
-  return phase.members.some(member => member.status !== 'completed')
+type DisclosureMode = 'clean' | 'running' | 'abnormal'
+
+interface DisclosureFacts {
+  readonly mode: DisclosureMode
+  readonly activityCount: number
 }
 
-type StatusDisclosureProps = Omit<DisclosureRowProps, 'open' | 'expandable' | 'onToggle'>
+interface DisclosureState extends DisclosureFacts {
+  readonly open: boolean
+  readonly pendingCleanCollapse: boolean
+}
 
-/* v8 ignore next -- DisclosureRow requires the callback but cannot invoke it when expandable is false. */
-const forcedOpenToggle = (): void => {}
+interface WorkflowDisclosureState {
+  readonly run: DisclosureState
+  readonly phases: ReadonlyMap<string, DisclosureState>
+}
 
-function ManualDisclosure(props: StatusDisclosureProps) {
-  const [open, setOpen] = useState(false)
-  return (
-    <DisclosureRow
-      {...props}
-      open={open}
-      expandable
-      onToggle={() => { setOpen(value => !value) }}
-    />
-  )
+type StatusDisclosureProps = Omit<DisclosureRowProps, 'expandable'>
+
+function StatusDisclosure(props: StatusDisclosureProps) {
+  return <DisclosureRow {...props} expandable />
 }
 
-function StatusDisclosure({ cleanCycleKey, requiresExpansion, ...props }: StatusDisclosureProps & {
-  /** Remount a clean Phase when its append-only member count changes between batched renders. */
-  readonly cleanCycleKey?: number | undefined
-  readonly requiresExpansion: boolean
-}) {
-  if (!requiresExpansion) return <ManualDisclosure key={cleanCycleKey} {...props} />
-  return <DisclosureRow {...props} open expandable={false} onToggle={forcedOpenToggle} />
+function abnormal(status: WorkflowRunStatus): boolean {
+  return status === 'failed' || status === 'cancelled' || status === 'interrupted'
+}
+
+function phaseDisclosureFacts(phase: WorkflowRunPhaseData): DisclosureFacts {
+  const mode = phase.members.some(member => abnormal(member.status))
+    ? 'abnormal'
+    : phase.members.some(member => member.status === 'running') ? 'running' : 'clean'
+  return { mode, activityCount: phase.members.length }
+}
+
+function runDisclosureFacts(
+  status: WorkflowRunStatus,
+  phases: readonly (readonly [string, DisclosureFacts])[],
+): DisclosureFacts {
+  const mode = abnormal(status) || phases.some(([, facts]) => facts.mode === 'abnormal')
+    ? 'abnormal'
+    : status === 'running' || phases.some(([, facts]) => facts.mode === 'running')
+      ? 'running'
+      : 'clean'
+  const activityCount = phases.reduce((count, [, facts]) => count + facts.activityCount, 0)
+  return { mode, activityCount }
+}
+
+function initialDisclosureState(facts: DisclosureFacts): DisclosureState {
+  return { ...facts, open: facts.mode !== 'clean', pendingCleanCollapse: false }
+}
+
+function advanceDisclosureState(
+  current: DisclosureState,
+  facts: DisclosureFacts,
+  focusWithin: boolean,
+): DisclosureState {
+  const sameFacts = current.mode === facts.mode && current.activityCount === facts.activityCount
+  if (sameFacts) {
+    if (!current.pendingCleanCollapse || focusWithin) return current
+    return { ...current, open: false, pendingCleanCollapse: false }
+  }
+  if (facts.mode === 'clean') {
+    const deferCollapse = current.open && focusWithin
+    return { ...facts, open: deferCollapse, pendingCleanCollapse: deferCollapse }
+  }
+  if (current.mode === 'clean' || (facts.mode === 'abnormal' && current.mode !== 'abnormal')) {
+    return { ...facts, open: true, pendingCleanCollapse: false }
+  }
+  return { ...facts, open: current.open, pendingCleanCollapse: false }
+}
+
+function focusIsWithin(element: HTMLElement | null | undefined): boolean {
+  if (element === null || element === undefined) return false
+  return element.contains(element.ownerDocument.activeElement)
+}
+
+function collapsePending(state: DisclosureState): DisclosureState {
+  if (!state.pendingCleanCollapse) return state
+  return { ...state, open: false, pendingCleanCollapse: false }
 }
 
 function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: WorkflowRunPanelProps['t']): string {
@@ -128,11 +182,12 @@ function navigableMembers(
   return result
 }
 
-function RunHeader({ children, count, name, requiresExpansion, status, t }: {
+function RunHeader({ children, count, name, onToggle, open, status, t }: {
   readonly children: ReactNode
   readonly count: number
   readonly name: string
-  readonly requiresExpansion: boolean
+  readonly onToggle: () => void
+  readonly open: boolean
   readonly status: WorkflowRunStatus
   readonly t: WorkflowRunPanelProps['t']
 }) {
@@ -140,7 +195,8 @@ function RunHeader({ children, count, name, requiresExpansion, status, t }: {
     <StatusDisclosure
       icon={<IconChevronRightOutline14 />}
       title={t('run.title', { name })}
-      requiresExpansion={requiresExpansion}
+      open={open}
+      onToggle={onToggle}
       expandOnRowClick
       previewChevron={false}
       keepContentWhenOpen
@@ -170,6 +226,21 @@ function MemberRow({ member, navigable, openSession, t }: {
   readonly t: WorkflowRunPanelProps['t']
 }) {
   const name = readableMember(member.label, t)
+  const buttonRef = useRef<HTMLButtonElement>(null)
+  const [keepFocusedButton, setKeepFocusedButton] = useState(navigable)
+  const renderButton = navigable || keepFocusedButton
+
+  useLayoutEffect(() => {
+    if (navigable) {
+      if (!keepFocusedButton) setKeepFocusedButton(true)
+      return
+    }
+    const button = buttonRef.current
+    if (button === null || button.ownerDocument.activeElement !== button) {
+      if (keepFocusedButton) setKeepFocusedButton(false)
+    }
+  }, [keepFocusedButton, navigable])
+
   const content = (
     <>
       <span className={css.dotSlot}><StateDot state={dotState(member.status)} /></span>
@@ -177,23 +248,33 @@ function MemberRow({ member, navigable, openSession, t }: {
       <span className={css.memberStatus} data-member-status-text>{t(STATUS_KEYS[member.status])}</span>
     </>
   )
-  if (!navigable) {
+  if (!renderButton) {
     return <div className={css.memberRow} data-member-status={member.status}>{content}</div>
   }
   return (
     <button
+      ref={buttonRef}
       type="button"
-      className={css.memberButton}
+      className={navigable ? css.memberButton : css.memberRow}
       data-member-status={member.status}
-      aria-label={t('member.open', { name })}
-      onClick={() => { openSession(member.childId) }}
+      aria-disabled={navigable ? undefined : true}
+      aria-label={navigable ? t('member.open', { name }) : name}
+      tabIndex={navigable ? undefined : -1}
+      onBlur={navigable ? undefined : () => { setKeepFocusedButton(false) }}
+      onClick={navigable ? () => { openSession(member.childId) } : undefined}
     >
       {content}
     </button>
   )
 }
 
-function PhaseSection({ phase, navigable, openSession, t }: {
+function PhaseSection({
+  contentRef, onContentBlur, onToggle, open, phase, navigable, openSession, t,
+}: {
+  readonly contentRef: (element: HTMLDivElement | null) => void
+  readonly onContentBlur: (event: FocusEvent<HTMLDivElement>) => void
+  readonly onToggle: () => void
+  readonly open: boolean
   readonly phase: WorkflowRunPhaseData
   readonly navigable: readonly SessionId[]
   readonly openSession: WorkflowRunInjected['openSession']
@@ -203,8 +284,8 @@ function PhaseSection({ phase, navigable, openSession, t }: {
     <StatusDisclosure
       icon={<IconChevronRightOutline14 />}
       title={readablePhase(phase.phase, t)}
-      cleanCycleKey={phase.members.length}
-      requiresExpansion={phaseRequiresExpansion(phase)}
+      open={open}
+      onToggle={onToggle}
       expandOnRowClick
       previewChevron={false}
       keepContentWhenOpen
@@ -220,7 +301,7 @@ function PhaseSection({ phase, navigable, openSession, t }: {
         </>
       )}
     >
-      <div className={css.members}>
+      <div ref={contentRef} className={css.members} onBlur={onContentBlur}>
         {phase.members.map(member => (
           <MemberRow
             key={member.seq}
@@ -237,34 +318,128 @@ function PhaseSection({ phase, navigable, openSession, t }: {
 
 /** Render one durable workflow run with status-driven run and phase disclosure. */
 export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t }: WorkflowRunPanelProps) {
-  const totalMembers = node.data.phases.reduce((count, phase) => count + phase.members.length, 0)
-  const requiresExpansion = node.data.status !== 'completed'
-    || node.data.phases.some(phaseRequiresExpansion)
+  const phaseFacts = useMemo(() => node.data.phases.map(phase => (
+    [phase.key, phaseDisclosureFacts(phase)] as const
+  )), [node.data.phases])
+  const runFacts = useMemo(
+    () => runDisclosureFacts(node.data.status, phaseFacts),
+    [node.data.status, phaseFacts],
+  )
+  const totalMembers = runFacts.activityCount
+  const [disclosures, setDisclosures] = useState<WorkflowDisclosureState>(() => ({
+    run: initialDisclosureState(runFacts),
+    phases: new Map(phaseFacts.map(([key, facts]) => [key, initialDisclosureState(facts)])),
+  }))
+  const runContentRef = useRef<HTMLDivElement>(null)
+  const phaseContentRefs = useRef(new Map<string, HTMLDivElement>())
   const navigable = useSessions(
     sessions => navigableMembers(sessions, node.data.phases, sessionId),
     shallowEqual,
   )
+
+  useLayoutEffect(() => {
+    setDisclosures((current) => {
+      const phases = new Map<string, DisclosureState>()
+      let phasesChanged = current.phases.size !== phaseFacts.length
+      let phaseBecameActive = false
+      for (const [key, facts] of phaseFacts) {
+        const previous = current.phases.get(key)
+        const next = previous === undefined
+          ? initialDisclosureState(facts)
+          : advanceDisclosureState(previous, facts, focusIsWithin(phaseContentRefs.current.get(key)))
+        phases.set(key, next)
+        if (next !== previous) phasesChanged = true
+        if (previous?.mode === 'clean' && facts.mode !== 'clean') phaseBecameActive = true
+      }
+      const advancedRun = advanceDisclosureState(
+        current.run,
+        runFacts,
+        focusIsWithin(runContentRef.current),
+      )
+      const run = phaseBecameActive && !advancedRun.open
+        ? { ...advancedRun, open: true, pendingCleanCollapse: false }
+        : advancedRun
+      return run !== current.run || phasesChanged ? { run, phases } : current
+    })
+  }, [disclosures.run.open, phaseFacts, runFacts])
+
+  const toggleRun = (): void => {
+    setDisclosures(current => ({
+      ...current,
+      run: {
+        ...current.run,
+        open: !current.run.open,
+        pendingCleanCollapse: false,
+      },
+    }))
+  }
+  const togglePhase = (key: string, facts: DisclosureFacts): void => {
+    setDisclosures((current) => {
+      const phases = new Map(current.phases)
+      /* v8 ignore next -- layout effects insert every rendered phase before user input can toggle it. */
+      const phase = phases.get(key) ?? initialDisclosureState(facts)
+      phases.set(key, {
+        ...phase,
+        open: !phase.open,
+        pendingCleanCollapse: false,
+      })
+      return { ...current, phases }
+    })
+  }
+  const settleRunBlur = (event: FocusEvent<HTMLDivElement>): void => {
+    if (event.currentTarget.contains(event.relatedTarget)) return
+    setDisclosures((current) => {
+      const run = collapsePending(current.run)
+      return run === current.run ? current : { ...current, run }
+    })
+  }
+  const settlePhaseBlur = (key: string, event: FocusEvent<HTMLDivElement>): void => {
+    if (event.currentTarget.contains(event.relatedTarget)) return
+    setDisclosures((current) => {
+      const phase = current.phases.get(key)
+      /* v8 ignore next -- the blur handler unmounts with the phase whose state it addresses. */
+      if (phase === undefined) return current
+      const next = collapsePending(phase)
+      if (next === phase) return current
+      const phases = new Map(current.phases)
+      phases.set(key, next)
+      return { ...current, phases }
+    })
+  }
+
   return (
     <section className={css.root} data-workflow-run data-run-status={node.data.status}>
       <RunHeader
         count={totalMembers}
         name={node.data.name}
-        requiresExpansion={requiresExpansion}
+        open={disclosures.run.open}
+        onToggle={toggleRun}
         status={node.data.status}
         t={t}
       >
-        <div className={css.phaseList}>
+        <div ref={runContentRef} className={css.phaseList} onBlur={settleRunBlur}>
           {node.data.phases.length === 0
             ? <span className={css.empty}>{t('run.empty')}</span>
-            : node.data.phases.map(phase => (
-              <PhaseSection
-                key={phase.key}
-                phase={phase}
-                navigable={navigable}
-                openSession={openSession}
-                t={t}
-              />
-            ))}
+            : node.data.phases.map((phase) => {
+              const facts = phaseDisclosureFacts(phase)
+              const disclosure = disclosures.phases.get(phase.key) ?? initialDisclosureState(facts)
+              return (
+                <PhaseSection
+                  key={phase.key}
+                  contentRef={(element) => {
+                    if (element === null) phaseContentRefs.current.delete(phase.key)
+                    else phaseContentRefs.current.set(phase.key, element)
+                  }}
+                  onContentBlur={(event) => { settlePhaseBlur(phase.key, event) }}
+                  onToggle={() => { togglePhase(phase.key, facts) }}
+                  open={disclosure.open}
+                  phase={phase}
+                  navigable={navigable}
+                  openSession={openSession}
+                  t={t}
+                />
+              )
+            })}
         </div>
       </RunHeader>
     </section>

+ 276 - 59
packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx

@@ -27,6 +27,7 @@ afterEach(cleanup)
 
 const PARENT_ID = 'parent' as SessionId
 const CHILD_ID = 'child-1' as SessionId
+const SECOND_ID = 'child-2' as SessionId
 
 interface ChatSnapshot {
   readonly nodes: ReadonlyMap<string, ChatConversationViewNode>
@@ -301,28 +302,62 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi
 }
 
 describe('WorkflowRunPanel', () => {
-  it('forces running run and phase content open without false disclosure controls', () => {
-    const view = render(<WorkflowRunPanel {...panelProps({
+  it('keeps live run and phase controls manual across ordinary updates and outer hiding', () => {
+    const running: WorkflowRunChatData = {
       name: 'audit', status: 'running', phases: [phase({ key: 'research', phase: 'Research' })],
+    }
+    const view = render(<WorkflowRunPanel {...panelProps(running)} />)
+    const runHeader = screen.getByRole('button', { name: /^audit/ })
+    const phaseHeader = screen.getByRole('button', { name: /Research/ })
+    expect(runHeader.getAttribute('aria-expanded')).toBe('true')
+    expect(phaseHeader.getAttribute('aria-expanded')).toBe('true')
+    expect(screen.getByText('worker')).toBeTruthy()
+
+    fireEvent.click(phaseHeader)
+    expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
+    expect(screen.queryByText('worker')).toBeNull()
+    fireEvent.click(runHeader)
+    expect(runHeader.getAttribute('aria-expanded')).toBe('false')
+
+    view.rerender(<WorkflowRunPanel {...panelProps({
+      ...running,
+      phases: [phase({
+        key: 'research', phase: 'Research',
+        members: [
+          { seq: 1, label: 'worker', childId: CHILD_ID, status: 'running' },
+          { seq: 2, label: 'second', childId: 'child-2' as SessionId, status: 'running' },
+        ],
+      })],
     })} />)
+    expect(runHeader.getAttribute('aria-expanded')).toBe('false')
+    expect(screen.queryByRole('button', { name: /Research/ })).toBeNull()
+    fireEvent.keyDown(runHeader, { key: 'ArrowDown' })
+    expect(runHeader.getAttribute('aria-expanded')).toBe('false')
+    fireEvent.keyDown(runHeader, { key: ' ' })
+    const updatedPhase = screen.getByRole('button', { name: /Research/ })
+    expect(updatedPhase.getAttribute('aria-expanded')).toBe('false')
+    expect(screen.getByText('运行中 2')).toBeTruthy()
+    fireEvent.keyDown(updatedPhase, { key: 'Enter' })
     expect(screen.getByText('worker')).toBeTruthy()
-    expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull()
+    expect(screen.getByText('second')).toBeTruthy()
+
+    fireEvent.click(runHeader)
+    expect(runHeader.getAttribute('aria-expanded')).toBe('false')
     expect(screen.queryByRole('button', { name: /Research/ })).toBeNull()
-    const rows = [...view.container.querySelectorAll('[data-disclosure-row]')]
-    expect(rows).toHaveLength(2)
-    for (const row of rows) {
-      expect(row.getAttribute('role')).toBeNull()
-      expect(row.getAttribute('tabindex')).toBeNull()
-      expect(row.getAttribute('aria-expanded')).toBeNull()
-      expect(row.getAttribute('data-expandable')).toBeNull()
-    }
+    fireEvent.keyDown(runHeader, { key: ' ' })
+    expect(screen.getByRole('button', { name: /Research/ }).getAttribute('aria-expanded')).toBe('true')
   })
 
-  it('folds each clean transition once and preserves review choices until activity returns', () => {
+  it('folds each normal completion once and opens a new same-key activity cycle', () => {
     const running: WorkflowRunChatData = {
       name: 'audit', status: 'running', phases: [phase()],
     }
     const view = render(<WorkflowRunPanel {...panelProps(running)} />)
+    const runningPhase = screen.getByRole('button', { name: /未分阶段/ })
+    fireEvent.click(runningPhase)
+    fireEvent.keyDown(runningPhase, { key: 'Enter' })
+    expect(screen.getByText('worker')).toBeTruthy()
+
     const phaseCompleted: WorkflowRunChatData = {
       ...running,
       phases: [phase({
@@ -338,27 +373,8 @@ describe('WorkflowRunPanel', () => {
     fireEvent.click(phaseHeader)
     expect(screen.getByText('done')).toBeTruthy()
 
-    const completed: WorkflowRunChatData = { ...phaseCompleted, status: 'completed' }
-    view.rerender(<WorkflowRunPanel {...panelProps(completed)} />)
-    const runHeader = screen.getByRole('button', { name: /^audit/ })
-    expect(runHeader.getAttribute('aria-expanded')).toBe('false')
-    expect(screen.queryByText('未分阶段')).toBeNull()
-    fireEvent.keyDown(runHeader, { key: 'ArrowDown' })
-    expect(runHeader.getAttribute('aria-expanded')).toBe('false')
-    fireEvent.keyDown(runHeader, { key: 'Enter' })
-    expect(runHeader.getAttribute('aria-expanded')).toBe('true')
-    const completedPhase = screen.getByRole('button', { name: /未分阶段/ })
-    fireEvent.keyDown(completedPhase, { key: 'Enter' })
-    expect(screen.getByText('done')).toBeTruthy()
-    fireEvent.keyDown(runHeader, { key: ' ' })
-    expect(runHeader.getAttribute('aria-expanded')).toBe('false')
-    fireEvent.keyDown(runHeader, { key: ' ' })
-    expect(runHeader.getAttribute('aria-expanded')).toBe('true')
-    fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
-    expect(screen.getByText('done')).toBeTruthy()
-
     const cleanUpdate: WorkflowRunChatData = {
-      ...completed,
+      ...phaseCompleted,
       phases: [phase({
         members: [{
           seq: 1, label: 'reviewed', childId: 'child-1' as SessionId, status: 'completed',
@@ -368,13 +384,44 @@ describe('WorkflowRunPanel', () => {
     view.rerender(<WorkflowRunPanel {...panelProps(cleanUpdate)} />)
     expect(screen.getByText('reviewed')).toBeTruthy()
 
-    view.rerender(<WorkflowRunPanel {...panelProps(running)} />)
-    expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull()
-    expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull()
-    expect(screen.getByText('worker')).toBeTruthy()
-    view.rerender(<WorkflowRunPanel {...panelProps(completed)} />)
+    fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
+    const runHeader = screen.getByRole('button', { name: /^audit/ })
+    fireEvent.click(runHeader)
+    const renewed: WorkflowRunChatData = {
+      name: 'audit', status: 'running',
+      phases: [phase({
+        members: [
+          { seq: 1, label: 'reviewed', childId: CHILD_ID, status: 'completed' },
+          { seq: 2, label: 'new', childId: 'child-2' as SessionId, status: 'running' },
+        ],
+      })],
+    }
+    view.rerender(<WorkflowRunPanel {...panelProps(renewed)} />)
+    expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('true')
+    expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true')
+    expect(screen.getByText('new')).toBeTruthy()
+
+    const renewedPhaseCompleted: WorkflowRunChatData = {
+      ...renewed,
+      phases: [phase({
+        members: [
+          { seq: 1, label: 'reviewed', childId: CHILD_ID, status: 'completed' },
+          { seq: 2, label: 'new', childId: 'child-2' as SessionId, status: 'completed' },
+        ],
+      })],
+    }
+    view.rerender(<WorkflowRunPanel {...panelProps(renewedPhaseCompleted)} />)
+    expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('true')
+    expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
+    fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
+
+    view.rerender(<WorkflowRunPanel {...panelProps({
+      ...renewedPhaseCompleted,
+      status: 'completed',
+    })} />)
     expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('false')
-    expect(screen.queryByText('未分阶段')).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
+    expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true')
   })
 
   it('refolds a phase when a complete activity cycle arrives as one clean update', () => {
@@ -399,10 +446,32 @@ describe('WorkflowRunPanel', () => {
     expect(screen.queryByText('second')).toBeNull()
   })
 
+  it('initializes a newly observed phase before it becomes interactive', () => {
+    const running: WorkflowRunChatData = {
+      name: 'dynamic-phase', status: 'running',
+      phases: [phase({ key: 'research', phase: 'Research' })],
+    }
+    const view = render(<WorkflowRunPanel {...panelProps(running)} />)
+    view.rerender(<WorkflowRunPanel {...panelProps({
+      ...running,
+      phases: [
+        ...running.phases,
+        phase({
+          key: 'build', phase: 'Build',
+          members: [{ seq: 2, label: 'builder', childId: SECOND_ID, status: 'running' }],
+        }),
+      ],
+    })} />)
+    const build = screen.getByRole('button', { name: /Build/ })
+    expect(build.getAttribute('aria-expanded')).toBe('true')
+    fireEvent.click(build)
+    expect(build.getAttribute('aria-expanded')).toBe('false')
+  })
+
   it('derives the zero-member running and completed states from the current run status', () => {
     const running: WorkflowRunChatData = { name: 'empty', status: 'running', phases: [] }
     const view = render(<WorkflowRunPanel {...panelProps(running)} />)
-    expect(screen.queryByRole('button', { name: /^empty/ })).toBeNull()
+    expect(screen.getByRole('button', { name: /^empty/ }).getAttribute('aria-expanded')).toBe('true')
     expect(screen.getByText('没有启动成员')).toBeTruthy()
     view.rerender(<WorkflowRunPanel {...panelProps({ ...running, status: 'completed' })} />)
     const header = screen.getByRole('button', { name: /^empty/ })
@@ -413,31 +482,60 @@ describe('WorkflowRunPanel', () => {
   })
 
   it.each(['failed', 'cancelled', 'interrupted'] as const)(
-    'bubbles a %s member to the run and keeps a matching run outcome open',
+    'initializes %s attention as an expanded disclosure that remains manually collapsible',
     (status) => {
-      const memberView = render(<WorkflowRunPanel {...panelProps({
-        name: 'member-outcome', status: 'completed',
+      render(<WorkflowRunPanel {...panelProps({
+        name: 'member-outcome', status,
         phases: [phase({
           members: [{ seq: 1, label: status, childId: CHILD_ID, status }],
         })],
       })} />)
-      expect(screen.queryByRole('button', { name: /^member-outcome/ })).toBeNull()
-      expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull()
+      const runHeader = screen.getByRole('button', { name: /^member-outcome/ })
+      const phaseHeader = screen.getByRole('button', { name: /未分阶段/ })
+      expect(runHeader.getAttribute('aria-expanded')).toBe('true')
+      expect(phaseHeader.getAttribute('aria-expanded')).toBe('true')
       expect(screen.getByText(status)).toBeTruthy()
-      memberView.unmount()
-
-      render(<WorkflowRunPanel {...panelProps({
-        name: 'run-outcome', status,
-        phases: [phase({
-          members: [{ seq: 1, label: 'done', childId: CHILD_ID, status: 'completed' }],
-        })],
-      })} />)
-      expect(screen.queryByRole('button', { name: /^run-outcome/ })).toBeNull()
-      expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
-      expect(screen.queryByText('done')).toBeNull()
+      fireEvent.click(phaseHeader)
+      fireEvent.click(runHeader)
+      expect(runHeader.getAttribute('aria-expanded')).toBe('false')
     },
   )
 
+  it('opens the first abnormal edge once and preserves later abnormal choices', () => {
+    const running: WorkflowRunChatData = {
+      name: 'audit', status: 'running', phases: [phase()],
+    }
+    const view = render(<WorkflowRunPanel {...panelProps(running)} />)
+    fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
+    fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
+
+    const failed: WorkflowRunChatData = {
+      name: 'audit', status: 'failed',
+      phases: [phase({
+        members: [{ seq: 1, label: 'failed', childId: CHILD_ID, status: 'failed' }],
+      })],
+    }
+    view.rerender(<WorkflowRunPanel {...panelProps(failed)} />)
+    expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('true')
+    expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true')
+    fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
+    fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
+
+    view.rerender(<WorkflowRunPanel {...panelProps({
+      ...failed,
+      phases: [phase({
+        members: [
+          { seq: 1, label: 'failed', childId: CHILD_ID, status: 'failed' },
+          { seq: 2, label: 'cancelled', childId: 'child-2' as SessionId, status: 'cancelled' },
+        ],
+      })],
+    })} />)
+    expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('false')
+    fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
+    expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
+    expect(screen.getByText('失败 1 · 已取消 1')).toBeTruthy()
+  })
+
   it('keeps clean sibling phases independent and preserves empty versus absent names', () => {
     render(<WorkflowRunPanel {...panelProps({
       name: 'audit', status: 'completed',
@@ -450,18 +548,26 @@ describe('WorkflowRunPanel', () => {
         }] }),
       ],
     })} />)
-    expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull()
+    const runHeader = screen.getByRole('button', { name: /^audit/ })
+    expect(runHeader.getAttribute('aria-expanded')).toBe('true')
     const cleanPhase = screen.getByRole('button', { name: /空阶段名/ })
     expect(cleanPhase.getAttribute('aria-expanded')).toBe('false')
-    expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull()
+    const activePhase = screen.getByRole('button', { name: /未分阶段/ })
+    expect(activePhase.getAttribute('aria-expanded')).toBe('true')
     expect(screen.queryByText('空成员名')).toBeNull()
     expect(screen.getByText('second')).toBeTruthy()
     fireEvent.click(cleanPhase)
     expect(screen.getByText('空成员名')).toBeTruthy()
     expect(screen.getByText('second')).toBeTruthy()
-    fireEvent.click(cleanPhase)
+    fireEvent.click(activePhase)
+    expect(screen.queryByText('second')).toBeNull()
+    expect(screen.getByText('空成员名')).toBeTruthy()
+    fireEvent.click(runHeader)
+    fireEvent.click(runHeader)
+    expect(screen.getByRole('button', { name: /空阶段名/ }).getAttribute('aria-expanded')).toBe('true')
+    expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
+    fireEvent.click(screen.getByRole('button', { name: /空阶段名/ }))
     expect(screen.queryByText('空成员名')).toBeNull()
-    expect(screen.getByText('second')).toBeTruthy()
   })
 
   it('renders mixed and interrupted aggregate status while attention stays visible', () => {
@@ -496,6 +602,107 @@ describe('WorkflowRunPanel', () => {
     expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(2)
   })
 
+  it('defers normal completion collapse until focused member content loses focus', () => {
+    const sessions = listState({
+      ids: [PARENT_ID, CHILD_ID, SECOND_ID],
+      byId: {
+        ...listState().byId,
+        [SECOND_ID]: {
+          id: SECOND_ID, displayTitle: 'second', parentId: PARENT_ID, origin: 'subagent',
+          running: true, blank: false, updatedAt: 0,
+        },
+      },
+    })
+    const running: WorkflowRunChatData = {
+      name: 'audit', status: 'running', phases: [phase({
+        members: [
+          { seq: 1, label: 'worker', childId: CHILD_ID, status: 'running' },
+          { seq: 2, label: 'second', childId: SECOND_ID, status: 'running' },
+        ],
+      })],
+    }
+    const view = render(<WorkflowRunPanel {...panelProps(running, sessions)} />)
+    const member = screen.getByRole('button', { name: '打开 worker' })
+    const second = screen.getByRole('button', { name: '打开 second' })
+    const runHeader = screen.getByRole('button', { name: /^audit/ })
+    const phaseHeader = screen.getByRole('button', { name: /未分阶段/ })
+    member.focus()
+    expect(document.activeElement).toBe(member)
+    fireEvent.blur(member, { relatedTarget: second })
+    second.focus()
+    expect(runHeader.getAttribute('aria-expanded')).toBe('true')
+    expect(phaseHeader.getAttribute('aria-expanded')).toBe('true')
+
+    const outside = document.createElement('button')
+    document.body.append(outside)
+    fireEvent.blur(second, { relatedTarget: outside })
+    outside.focus()
+    expect(runHeader.getAttribute('aria-expanded')).toBe('true')
+    expect(phaseHeader.getAttribute('aria-expanded')).toBe('true')
+    member.focus()
+
+    view.rerender(<WorkflowRunPanel {...panelProps({
+      name: 'audit', status: 'completed',
+      phases: [phase({
+        members: [
+          { seq: 1, label: 'worker', childId: CHILD_ID, status: 'completed' },
+          { seq: 2, label: 'second', childId: SECOND_ID, status: 'completed' },
+        ],
+      })],
+    }, sessions)} />)
+    expect(runHeader.getAttribute('aria-expanded')).toBe('true')
+    expect(phaseHeader.getAttribute('aria-expanded')).toBe('true')
+    const retained = screen.getByRole('button', { name: 'worker' })
+    expect(retained.getAttribute('aria-disabled')).toBe('true')
+    expect(document.activeElement).toBe(retained)
+
+    fireEvent.blur(retained, { relatedTarget: outside })
+    outside.focus()
+    expect(document.activeElement).toBe(outside)
+    expect(runHeader.getAttribute('aria-expanded')).toBe('false')
+    fireEvent.click(runHeader)
+    const completedPhase = screen.getByRole('button', { name: /未分阶段/ })
+    expect(completedPhase.getAttribute('aria-expanded')).toBe('false')
+    fireEvent.click(completedPhase)
+    expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull()
+    expect(screen.getByText('worker')).toBeTruthy()
+    outside.remove()
+  })
+
+  it('settles a deferred phase close when the user hides the outer run', () => {
+    const running: WorkflowRunChatData = {
+      name: 'audit', status: 'running', phases: [phase()],
+    }
+    const view = render(<WorkflowRunPanel {...panelProps(running)} />)
+    const member = screen.getByRole('button', { name: '打开 worker' })
+    member.focus()
+    view.rerender(<WorkflowRunPanel {...panelProps({
+      ...running,
+      phases: [phase({
+        members: [{ seq: 1, label: 'worker', childId: CHILD_ID, status: 'completed' }],
+      })],
+    })} />)
+    const runHeader = screen.getByRole('button', { name: /^audit/ })
+    expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true')
+    fireEvent.click(runHeader)
+    expect(runHeader.getAttribute('aria-expanded')).toBe('false')
+    fireEvent.click(runHeader)
+    expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
+  })
+
+  it('reinitializes manual choices from durable facts after a renderer remount', () => {
+    const data: WorkflowRunChatData = {
+      name: 'audit', status: 'running', phases: [phase()],
+    }
+    const view = render(<WorkflowRunPanel {...panelProps(data)} />)
+    fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
+    fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
+    view.unmount()
+    render(<WorkflowRunPanel {...panelProps(data)} />)
+    expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('true')
+    expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('true')
+  })
+
   it('opens only a running ordinary-list subagent proven to have this parent', () => {
     const data: WorkflowRunChatData = {
       name: 'audit', status: 'running', phases: [phase()],
@@ -506,6 +713,16 @@ describe('WorkflowRunPanel', () => {
     expect(openSession).toHaveBeenCalledWith('child-1')
   })
 
+  it('promotes a running member when its ordinary Session row arrives', () => {
+    const data: WorkflowRunChatData = {
+      name: 'audit', status: 'running', phases: [phase()],
+    }
+    const view = render(<WorkflowRunPanel {...panelProps(data, listState({ ids: [PARENT_ID] }))} />)
+    expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull()
+    view.rerender(<WorkflowRunPanel {...panelProps(data, listState())} />)
+    expect(screen.getByRole('button', { name: '打开 worker' })).toBeTruthy()
+  })
+
   it.each([
     ['not in ordinary list', listState({ ids: [PARENT_ID] }), 'running'],
     ['remote row', listState({ byId: {