fix(workflow): preserve disclosure intent across completion

This commit is contained in:
pku-xht committed 2026-08-17 17:31:00 +08:00
1 parent 941e5c5061
commit 380030c48f
4 files changed
+104 -84

No files matched your search

@@ -1,23 +1,6 @@
- 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 -1
View File
@@ -93,7 +93,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
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)
const liveSnapshot = await captureStableAria(page, '[data-workflow-run]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_LIVE_EXPECTED, liveSnapshot, MODE)
await phaseDisclosure.press('Enter')
await member.waitFor()
@@ -147,6 +147,16 @@ function collapsePending(state: DisclosureState): DisclosureState {
return { ...state, open: false, pendingCleanCollapse: false }
}
function existingPhaseState(
phases: ReadonlyMap<string, DisclosureState>,
key: string,
): DisclosureState {
const phase = phases.get(key)
/* v8 ignore next -- mounted phase callbacks are created from this owner map. */
if (phase === undefined) throw new Error(`Missing disclosure state for phase ${key}`)
return phase
}
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)
@@ -226,20 +236,8 @@ 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 [focused, setFocused] = useState(false)
const renderButton = navigable || focused
const content = (
<>
@@ -253,14 +251,14 @@ function MemberRow({ member, navigable, openSession, t }: {
}
return (
<button
ref={buttonRef}
type="button"
className={navigable ? css.memberButton : css.memberRow}
data-member-status={member.status}
aria-disabled={navigable ? undefined : true}
aria-label={navigable ? t('member.open', { name }) : name}
tabIndex={navigable ? undefined : -1}
onBlur={navigable ? undefined : () => { setKeepFocusedButton(false) }}
onFocus={() => { setFocused(true) }}
onBlur={() => { setFocused(false) }}
onClick={navigable ? () => { openSession(member.childId) } : undefined}
>
{content}
@@ -269,10 +267,10 @@ function MemberRow({ member, navigable, openSession, t }: {
}
function PhaseSection({
contentRef, onContentBlur, onToggle, open, phase, navigable, openSession, t,
contentRef, onBlur, onToggle, open, phase, navigable, openSession, t,
}: {
readonly contentRef: (element: HTMLDivElement | null) => void
readonly onContentBlur: (event: FocusEvent<HTMLDivElement>) => void
readonly onBlur: (event: FocusEvent<HTMLDivElement>) => void
readonly onToggle: () => void
readonly open: boolean
readonly phase: WorkflowRunPhaseData
@@ -281,38 +279,39 @@ function PhaseSection({
readonly t: WorkflowRunPanelProps['t']
}) {
return (
<StatusDisclosure
icon={<IconChevronRightOutline14 />}
title={readablePhase(phase.phase, t)}
open={open}
onToggle={onToggle}
expandOnRowClick
previewChevron={false}
keepContentWhenOpen
className={css.phase}
rowClassName={css.phaseHeader}
leadingClassName={css.phaseLeading}
titleClassName={css.phaseTitle}
collapsedContent={(
<>
<span className={css.separator} aria-hidden />
<span className={css.phaseCount} data-phase-count>{memberCount(phase.members.length, t)}</span>
<span className={css.phaseStatus} data-phase-status-text>{phaseStatusSummary(phase.members, t)}</span>
</>
)}
>
<div ref={contentRef} className={css.members} onBlur={onContentBlur}>
{phase.members.map(member => (
<MemberRow
key={member.seq}
member={member}
navigable={navigable.includes(member.childId)}
openSession={openSession}
t={t}
/>
))}
</div>
</StatusDisclosure>
<div className={css.phase} onBlur={onBlur}>
<StatusDisclosure
icon={<IconChevronRightOutline14 />}
title={readablePhase(phase.phase, t)}
open={open}
onToggle={onToggle}
expandOnRowClick
previewChevron={false}
keepContentWhenOpen
rowClassName={css.phaseHeader}
leadingClassName={css.phaseLeading}
titleClassName={css.phaseTitle}
collapsedContent={(
<>
<span className={css.separator} aria-hidden />
<span className={css.phaseCount} data-phase-count>{memberCount(phase.members.length, t)}</span>
<span className={css.phaseStatus} data-phase-status-text>{phaseStatusSummary(phase.members, t)}</span>
</>
)}
>
<div ref={contentRef} className={css.members}>
{phase.members.map(member => (
<MemberRow
key={member.seq}
member={member}
navigable={navigable.includes(member.childId)}
openSession={openSession}
t={t}
/>
))}
</div>
</StatusDisclosure>
</div>
)
}
@@ -341,7 +340,7 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t
setDisclosures((current) => {
const phases = new Map<string, DisclosureState>()
let phasesChanged = current.phases.size !== phaseFacts.length
let phaseBecameActive = false
let phaseStartedCycle = false
for (const [key, facts] of phaseFacts) {
const previous = current.phases.get(key)
const next = previous === undefined
@@ -349,14 +348,17 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t
: 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
if (previous?.mode === 'clean'
&& (facts.mode !== 'clean' || facts.activityCount !== previous.activityCount)) {
phaseStartedCycle = true
}
}
const advancedRun = advanceDisclosureState(
current.run,
runFacts,
focusIsWithin(runContentRef.current),
)
const run = phaseBecameActive && !advancedRun.open
const run = phaseStartedCycle && !advancedRun.open
? { ...advancedRun, open: true, pendingCleanCollapse: false }
: advancedRun
return run !== current.run || phasesChanged ? { run, phases } : current
@@ -373,11 +375,10 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t
},
}))
}
const togglePhase = (key: string, facts: DisclosureFacts): void => {
const togglePhase = (key: string): 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)
const phase = existingPhaseState(phases, key)
phases.set(key, {
...phase,
open: !phase.open,
@@ -386,19 +387,17 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t
return { ...current, phases }
})
}
const settleRunBlur = (event: FocusEvent<HTMLDivElement>): void => {
const settleRunBlur = (event: FocusEvent<HTMLElement>): 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 => {
const settlePhaseBlur = (key: string, event: FocusEvent<HTMLElement>): 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 phase = existingPhaseState(current.phases, key)
const next = collapsePending(phase)
if (next === phase) return current
const phases = new Map(current.phases)
@@ -408,7 +407,12 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t
}
return (
<section className={css.root} data-workflow-run data-run-status={node.data.status}>
<section
className={css.root}
data-workflow-run
data-run-status={node.data.status}
onBlur={settleRunBlur}
>
<RunHeader
count={totalMembers}
name={node.data.name}
@@ -417,7 +421,7 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t
status={node.data.status}
t={t}
>
<div ref={runContentRef} className={css.phaseList} onBlur={settleRunBlur}>
<div ref={runContentRef} className={css.phaseList}>
{node.data.phases.length === 0
? <span className={css.empty}>{t('run.empty')}</span>
: node.data.phases.map((phase) => {
@@ -430,8 +434,8 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t
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) }}
onBlur={(event) => { settlePhaseBlur(phase.key, event) }}
onToggle={() => { togglePhase(phase.key) }}
open={disclosure.open}
phase={phase}
navigable={navigable}
@@ -435,12 +435,16 @@ describe('WorkflowRunPanel', () => {
const phaseView = render(<WorkflowRunPanel {...panelProps(phaseClean)} />)
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
expect(screen.getByText('first')).toBeTruthy()
const runHeader = screen.getByRole('button', { name: /^phase-cycle/ })
fireEvent.click(runHeader)
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
phaseView.rerender(<WorkflowRunPanel {...panelProps({
...phaseClean,
phases: [phase({ members: [firstMember, {
seq: 2, label: 'second', childId: 'child-2' as SessionId, status: 'completed',
}] })],
})} />)
expect(runHeader.getAttribute('aria-expanded')).toBe('true')
expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText('first')).toBeNull()
expect(screen.queryByText('second')).toBeNull()
@@ -510,7 +514,7 @@ describe('WorkflowRunPanel', () => {
fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
const failed: WorkflowRunChatData = {
name: 'audit', status: 'failed',
name: 'audit', status: 'running',
phases: [phase({
members: [{ seq: 1, label: 'failed', childId: CHILD_ID, status: 'failed' }],
})],
@@ -669,6 +673,35 @@ describe('WorkflowRunPanel', () => {
outside.remove()
})
it('handles a pointer blur and header click as one pending-completion close', () => {
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({
name: 'audit', status: 'completed',
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')
fireEvent.blur(phaseHeader, { relatedTarget: runHeader })
runHeader.focus()
fireEvent.click(runHeader)
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
})
it('settles a deferred phase close when the user hides the outer run', () => {
const running: WorkflowRunChatData = {
name: 'audit', status: 'running', phases: [phase()],