mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
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:
@@ -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=null(runs 清空):不触发
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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 是同一 runId,prev 是 running,curr 是 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 的状态转换触发:切到已完成的 tab(prev 是别的 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 title(0 = All = undefined)
|
||||
const selectedPhaseTitle = clampedPhase === 0 ? undefined : phases[clampedPhase - 1]?.title;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user