fix(workflow): 终态前补发 phase_done,面板自动退出 running→terminal 转换

runWorkflow:脚本结束时 hook.phase 不会触发最后一个 phase 的 phase_done,
UI 左栏会永远显示 running。三路径(completed/killed/failed)统一在 run_done
之前补发 emitTerminalPhaseDone。

WorkflowsPanel:抽 isRunTerminatedTransition 纯函数判定 running → terminal,
面板 useEffect 检测到转换后自动退出聚焦。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-14 14:45:02 +08:00
parent 15216eb2e6
commit 8bc1a33f3c
4 changed files with 187 additions and 23 deletions

View File

@@ -5,7 +5,7 @@ import { wrappedRender as render } from '@anthropic/ink';
import { SentryErrorBoundary } from '../../components/SentryErrorBoundary.js';
import type { RunProgress } from '../progress/store.js';
import { call as panelCall } from '../panel/panelCall.js';
import { clampSelected, WorkflowsPanel } from '../panel/WorkflowsPanel.js';
import { clampSelected, isRunTerminatedTransition, WorkflowsPanel } from '../panel/WorkflowsPanel.js';
import { truncateLabel } from '../panel/AgentList.js';
import { STATUS_DOT } from '../panel/status.js';
import { __resetWorkflowServiceForTests, getWorkflowService } from '../service.js';
@@ -162,3 +162,36 @@ test('WorkflowsPanel mount 触发一次 loadPersistedRuns', async () => {
__resetWorkflowServiceForTests();
}
});
// focused run 从 running 转 terminal 时面板自动 onDone()800ms 延迟让用户看到终态)。
// 仅同 runId 的状态转换触发:切到已完成 tab 不退出;打开历史面板也不退出。
// 转换判定逻辑抽成 isRunTerminatedTransition 纯函数便于离线单测Ink test 模式不
// 自动 pump concurrent 状态更新,集成测试不可靠)。
test('isRunTerminatedTransition同 runId running → terminal 触发;其它情况不触发', () => {
const running = { runId: 'r1', status: 'running' as const };
const completed = { runId: 'r1', status: 'completed' as const };
const failed = { runId: 'r1', status: 'failed' as const };
const killed = { runId: 'r1', status: 'killed' as const };
// 同 run running → terminal三种 terminal 都触发
expect(isRunTerminatedTransition(running, completed)).toBe(true);
expect(isRunTerminatedTransition(running, failed)).toBe(true);
expect(isRunTerminatedTransition(running, killed)).toBe(true);
// prev=null打开历史面板不触发
expect(isRunTerminatedTransition(null, completed)).toBe(false);
// curr=nullruns 清空):不触发
expect(isRunTerminatedTransition(running, null)).toBe(false);
// 不同 runId切 tab不触发
expect(isRunTerminatedTransition({ runId: 'r1', status: 'running' }, { runId: 'r2', status: 'completed' })).toBe(
false,
);
// 同 run 但 prev 非 running已是 terminal 又重渲染):不触发
expect(isRunTerminatedTransition(completed, completed)).toBe(false);
expect(isRunTerminatedTransition(killed, completed)).toBe(false);
// 同 run running → running无变化不触发
expect(isRunTerminatedTransition(running, running)).toBe(false);
});

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useSyncExternalStore } from 'react';
import React, { useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { Box, Dialog, Text, useAnimationFrame } from '@anthropic/ink';
import type { Theme } from '@anthropic/ink';
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js';
@@ -22,6 +22,25 @@ export function clampSelected(selected: number, len: number): number {
return Math.min(n, len - 1);
}
/**
* 判断 focused run 是否完成了 running → terminal 的状态转换(用于面板自动退出)。
* 抽成纯函数便于单测;面板 useEffect 内部直接调用。
*
* 触发条件prev 与 curr 是同一 runIdprev 是 runningcurr 是 completed/failed/killed。
* - 打开历史面板prev=null不触发
* - 切到已完成 tab不同 runId不触发
* - 同 run running → terminal触发
*/
export function isRunTerminatedTransition(
prev: { runId: string; status: RunProgress['status'] } | null,
curr: { runId: string; status: RunProgress['status'] } | null,
): boolean {
if (!prev || !curr) return false;
if (prev.runId !== curr.runId) return false;
if (prev.status !== 'running') return false;
return curr.status === 'completed' || curr.status === 'failed' || curr.status === 'killed';
}
/**
* /workflows 主面板:三区焦点模型(顶 tab + 左 phase 侧栏 + 右 agent 列表)。
*
@@ -74,6 +93,21 @@ export function WorkflowsPanel({
const phaseRowCount = phases.length + 1;
const clampedPhase = clampSelected(selectedPhaseIndex, phaseRowCount);
// focused run 从 running 转 terminal 时自动退出面板800ms 延迟让用户看到 ✓/✗ 终态)。
// 仅同 runId 的状态转换触发:切到已完成的 tabprev 是别的 run不退出打开历史面板
// prev=null也不退出。否则 agent 在 Workflow tool 等结果时被面板挡住,用户必须手动 q。
const prevFocusedRef = useRef<{ runId: string; status: RunProgress['status'] } | null>(null);
useEffect(() => {
const curr = focused ? { runId: focused.runId, status: focused.status } : null;
const prev = prevFocusedRef.current;
prevFocusedRef.current = curr;
if (!isRunTerminatedTransition(prev, curr)) return;
const timer = setTimeout(() => onDone(), 800);
return (): void => {
clearTimeout(timer);
};
}, [focused?.runId, focused?.status, onDone]);
// 选中 phase title0 = All = undefined
const selectedPhaseTitle = clampedPhase === 0 ? undefined : phases[clampedPhase - 1]?.title;