Forráskód Böngészése

fix(workflow): preserve precise completion focus

pku-xht 3 hete
szülő
commit
7720b2258d

+ 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: a783ede82442cfb28ed4c0a2fd394677728949d8
-2026-08-11-workflow-run-status-driven-disclosure.zh.md: 0d047bf7383f68ff7ec23d3602789e776c85d2cc
+2026-08-11-workflow-run-status-driven-disclosure.md: e97ead90460f21d4a27b49dcdabbcafc8f193cda
+2026-08-11-workflow-run-status-driven-disclosure.zh.md: ef3da3ee7cbffbb901a4b71a920bd53c2965c827

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

@@ -14,7 +14,7 @@ The renderer already receives every durable lifecycle fact from the workflow Con
 
 `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.
 
-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.
+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: it closes an open phase review and, while the run remains active, opens the outer run once without adding an activity epoch or durable field. After an automatic action, mouse, Enter, and Space control the level until another defined edge occurs.
 
 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.
 

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

@@ -14,7 +14,7 @@ renderer 已经从工作流 Conversation Node 收到全部持久生命周期事
 
 `WorkflowRunPanel` 持有一项运行 disclosure 本地状态,以及一张按现有 phase key 索引的 Phase 状态表。全部成员都已完成时,Phase 为干净状态;任一成员失败、已取消或已中断时为异常状态;其余情况为运行状态。运行自身或任一 Phase 异常时,运行处于异常状态;运行自身或任一 Phase 正在运行时,运行处于运行状态;只有运行与全部 Phase 都正常完成时才处于干净状态。挂载时,运行和异常层级默认展开,干净层级默认折叠。
 
-每个层级记录当前模式、仅追加成员数、开合选择和待执行的干净折叠。Phase 从干净状态进入新活动时,该 Phase 与外层运行自动展开一次;连续运行或异常区间内的普通更新保留用户选择,首次进入异常状态时自动展开一次,进入干净状态时自动折叠一次。若新增成员及其正常完成在同一次渲染中送达,Phase 会保持干净但成员数改变;该变化会折叠已打开的复盘,而无需增加 activity epoch 或持久字段。自动动作完成后,鼠标、Enter 和 Space 控制该层级,直到出现下一项约定边沿。
+每个层级记录当前模式、仅追加成员数、开合选择和待执行的干净折叠。Phase 从干净状态进入新活动时,该 Phase 与外层运行自动展开一次;连续运行或异常区间内的普通更新保留用户选择,首次进入异常状态时自动展开一次,进入干净状态时自动折叠一次。若新增成员及其正常完成在同一次渲染中送达,Phase 会保持干净但成员数改变;该变化会折叠已打开的 Phase 复盘,并在运行仍处于活动状态时自动展开外层运行一次,而无需增加 activity epoch 或持久字段。自动动作完成后,鼠标、Enter 和 Space 控制该层级,直到出现下一项约定边沿。
 
 外层 disclosure 隐藏子内容时,Phase 状态仍留在 `WorkflowRunPanel`,因此关闭并重新打开运行会恢复各 Phase 选择。Phase 被移除时,其表项同时清理;renderer remount 会从当前持久事实重建每个层级,而不恢复更早选择。
 

+ 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: 837ae237258430d942ebdd28a0f4d6efd2876de8
-README.zh.md: dfec61005f8f06637602722041f3233219b7216b
+README.md: 5093050ac7bd7e5d8a8779c4fe9848ee4f7da28b
+README.zh.md: f7b652d87dc01e5161eac8ea3fc9025136feca90

+ 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 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.
+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. If an entire new clean cycle arrives in one render while the run remains active, the phase finishes folded but the outer run opens once to expose its updated summary. 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。挂载时,运行中、失败、已取消和已中断层级默认展开,全部完成的层级默认折叠;此后用户可以点击整行,或按 Enter、Space 切换任一层级。普通运行更新保留当前选择,首次异常边沿只自动展开一次,正常完成只自动折叠一次;已完成阶段在同一 phase key 下开始新的运行成员时,该 Phase 与外层运行会再次自动展开。完成状态会立即更新,但只要焦点仍位于展开内容内,自动折叠就会等待焦点离开。Phase 选择由 `WorkflowRunPanel` 持有,因此关闭并重新打开外层运行不会重置它们;renderer remount 会从持久事实重建每层的初始选择。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。
+运行和每个阶段在所有状态下都是受控 disclosure。挂载时,运行中、失败、已取消和已中断层级默认展开,全部完成的层级默认折叠;此后用户可以点击整行,或按 Enter、Space 切换任一层级。普通运行更新保留当前选择,首次异常边沿只自动展开一次,正常完成只自动折叠一次;已完成阶段在同一 phase key 下开始新的运行成员时,该 Phase 与外层运行会再次自动展开。若一个完整的新干净周期在同一次渲染中送达,且运行仍处于活动状态,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)`;远程、仅地址化、父级不符或终态的行都不可交互。
 

+ 26 - 10
packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx

@@ -1,6 +1,6 @@
 import {
   useLayoutEffect, useMemo, useRef, useState,
-  type FocusEvent, type ReactNode,
+  type FocusEvent, type MouseEvent, type ReactNode,
 } from 'react'
 import {
   DisclosureRow, IconChevronRightOutline14, StateDot,
@@ -157,6 +157,13 @@ function existingPhaseState(
   return phase
 }
 
+function preventPendingHeaderFocus(event: MouseEvent<HTMLElement>): void {
+  const header = event.currentTarget.querySelector('[data-disclosure-row]')
+  /* v8 ignore next -- DisclosureRow always renders its header before the content. */
+  if (header === null) throw new Error('Missing disclosure header')
+  if (header.contains(event.target as Node)) event.preventDefault()
+}
+
 function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: WorkflowRunPanelProps['t']): string {
   const counts = new Map<WorkflowRunStatus, number>()
   for (const member of members) counts.set(member.status, (counts.get(member.status) ?? 0) + 1)
@@ -267,19 +274,24 @@ function MemberRow({ member, navigable, openSession, t }: {
 }
 
 function PhaseSection({
-  contentRef, onBlur, onToggle, open, phase, navigable, openSession, t,
+  contentRef, onContentBlur, onToggle, open, pendingCleanCollapse,
+  phase, navigable, openSession, t,
 }: {
   readonly contentRef: (element: HTMLDivElement | null) => void
-  readonly onBlur: (event: FocusEvent<HTMLDivElement>) => void
+  readonly onContentBlur: (event: FocusEvent<HTMLDivElement>) => void
   readonly onToggle: () => void
   readonly open: boolean
+  readonly pendingCleanCollapse: boolean
   readonly phase: WorkflowRunPhaseData
   readonly navigable: readonly SessionId[]
   readonly openSession: WorkflowRunInjected['openSession']
   readonly t: WorkflowRunPanelProps['t']
 }) {
   return (
-    <div className={css.phase} onBlur={onBlur}>
+    <div
+      className={css.phase}
+      onMouseDownCapture={pendingCleanCollapse ? preventPendingHeaderFocus : undefined}
+    >
       <StatusDisclosure
         icon={<IconChevronRightOutline14 />}
         title={readablePhase(phase.phase, t)}
@@ -299,7 +311,7 @@ function PhaseSection({
           </>
         )}
       >
-        <div ref={contentRef} className={css.members}>
+        <div ref={contentRef} className={css.members} onBlur={onContentBlur}>
           {phase.members.map(member => (
             <MemberRow
               key={member.seq}
@@ -336,6 +348,7 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t
     shallowEqual,
   )
 
+  // Outer hiding unmounts Phase content without a dependable blur event, so this edge settles deferred closes.
   useLayoutEffect(() => {
     setDisclosures((current) => {
       const phases = new Map<string, DisclosureState>()
@@ -387,14 +400,14 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t
       return { ...current, phases }
     })
   }
-  const settleRunBlur = (event: FocusEvent<HTMLElement>): void => {
+  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<HTMLElement>): void => {
+  const settlePhaseBlur = (key: string, event: FocusEvent<HTMLDivElement>): void => {
     if (event.currentTarget.contains(event.relatedTarget)) return
     setDisclosures((current) => {
       const phase = existingPhaseState(current.phases, key)
@@ -411,7 +424,9 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t
       className={css.root}
       data-workflow-run
       data-run-status={node.data.status}
-      onBlur={settleRunBlur}
+      onMouseDownCapture={disclosures.run.pendingCleanCollapse
+        ? preventPendingHeaderFocus
+        : undefined}
     >
       <RunHeader
         count={totalMembers}
@@ -421,7 +436,7 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t
         status={node.data.status}
         t={t}
       >
-        <div ref={runContentRef} 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) => {
@@ -434,9 +449,10 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t
                     if (element === null) phaseContentRefs.current.delete(phase.key)
                     else phaseContentRefs.current.set(phase.key, element)
                   }}
-                  onBlur={(event) => { settlePhaseBlur(phase.key, event) }}
+                  onContentBlur={(event) => { settlePhaseBlur(phase.key, event) }}
                   onToggle={() => { togglePhase(phase.key) }}
                   open={disclosure.open}
+                  pendingCleanCollapse={disclosure.pendingCleanCollapse}
                   phase={phase}
                   navigable={navigable}
                   openSession={openSession}

+ 36 - 2
packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx

@@ -702,15 +702,49 @@ describe('WorkflowRunPanel', () => {
     const phaseHeader = screen.getByRole('button', { name: /未分阶段/ })
     const runHeader = screen.getByRole('button', { name: /^audit/ })
 
+    fireEvent.mouseDown(retained)
+    expect(document.activeElement).toBe(retained)
+    fireEvent.mouseDown(phaseHeader)
+    fireEvent.click(phaseHeader)
+    expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
+    expect(runHeader.getAttribute('aria-expanded')).toBe('true')
+
+    fireEvent.mouseDown(runHeader)
+    fireEvent.click(runHeader)
+    expect(runHeader.getAttribute('aria-expanded')).toBe('false')
+  })
+
+  it('settles pending completion when keyboard focus moves from content to its header', () => {
+    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 retained = screen.getByRole('button', { name: 'worker' })
+    const phaseHeader = screen.getByRole('button', { name: /未分阶段/ })
+    const runHeader = screen.getByRole('button', { name: /^audit/ })
     fireEvent.blur(retained, { relatedTarget: phaseHeader })
     phaseHeader.focus()
-    fireEvent.click(phaseHeader)
     expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
     expect(runHeader.getAttribute('aria-expanded')).toBe('true')
 
+    view.rerender(<WorkflowRunPanel {...panelProps({
+      ...running,
+      status: 'completed',
+      phases: [phase({
+        members: [{ seq: 1, label: 'worker', childId: CHILD_ID, status: 'completed' }],
+      })],
+    })} />)
+    expect(runHeader.getAttribute('aria-expanded')).toBe('true')
     fireEvent.blur(phaseHeader, { relatedTarget: runHeader })
     runHeader.focus()
-    fireEvent.click(runHeader)
     expect(runHeader.getAttribute('aria-expanded')).toBe('false')
   })