Files
claude-code/src/workflow/panel/WorkflowsPanel.tsx
claude-code-best cd222b8e65 Fixture/flick (#1280)
* fix: 终端内容溢出 viewport 时的重影 bug

主屏幕模式下 frame 持续溢出 viewport 时,cursor-restore LF 把内容滚入 scrollback
导致相对光标追踪漂移,可见区 diff 落到错误行产生重影(重复 banner / 错位)。
扩展 log-update overflow 分支为无条件 fullReset(含 \x1b[3J 清 scrollback),
并将主屏 self-healing 清屏从 ERASE_SCREEN (CSI 2 J) 换成 ERASE_DOWN (CSI J),
避免 xterm.js / VSCode 集成终端的 scrollback 边界副作用。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 删除 3 个孤立诊断脚本

- scripts/verify-autofix-pr.ts: 一次性 autofix-pr 验证脚本,全仓零引用
- scripts/smoke-test-commands.ts: 开发期冒烟测试脚本,无任何 import
- scripts/probe-subscription-endpoints.ts: 手动 endpoint 探针,无引用

均不在 package.json scripts、build.ts、vite.config.ts、CI workflows 中。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 self-hosted-runner stub 及其 cli.tsx fast-path

- 删除 src/self-hosted-runner/main.ts(自动生成的 Promise.resolve() stub)
- 同步移除 src/entrypoints/cli.tsx 中 feature('SELF_HOSTED_RUNNER') 守卫的 fast-path 分支
- 该 flag 不在 build.ts DEFAULT_BUILD_FEATURES 也不在 dev 默认列表,所有默认配置下整段为构建期死代码

删除 stub 单独会留下未解析的动态 import,必须协同拆除。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 删除 agentSdkTypes 中三个 not-implemented stub

移除 watchScheduledTasks、buildMissedTaskNotification、connectRemoteControl 三个 stub 函数(函数体仅 throw new Error('not implemented')),以及仅被这些 stub 引用的孤儿类型(ScheduledTasksHandle、ConnectRemoteControlOptions、RemoteControlHandle、InboundPrompt 等)。

全仓零外部引用。buildMissedTaskNotification 在 src/utils/cronScheduler.ts 有真实可用实现,未受影响。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 Cursor.ts 中未引用的 kill ring 访问器

- 删除 getKillRingItem、getKillRingSize、clearKillRing、canYankPop(全仓零引用的独立 export)
- 移除 VIM_WORD_CHAR_REGEX 的 export 关键字(仍由 isVimWordChar 内部使用,保留常量本体)

kill ring 特性本身仍活跃(getLastKill/pushToKillRing/yankPop 在 useSearchInput/useTextInput 使用),仅这几个孤儿 helper 未接入。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 insights.ts 中未引用的导出

- 删除 deduplicateSessionBranches(全仓零调用,含 JSDoc)
- 删除 buildExportData(全仓零调用,原 S3 上传路径实际用 HTML 而非 JSON)
- InsightsExport 仅移除 export 关键字(保留类型本体,仍作为内部返回类型)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 autonomyCommandSpec.ts 中未引用的导出

- 删除 AUTONOMY_CLI(CLI 子命令描述对象,零引用;handler 仅用 AUTONOMY_USAGE)
- 删除 AUTONOMY_COMMAND_DESCRIPTION(值已在 main.tsx:5181 内联)
- ParsedAutonomyCommand 仅移除 export 关键字(保留类型作为 parseAutonomyArgs 返回类型)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 binaryCheck/claudeAiLimits/codeIndexing 中未引用的导出

- binaryCheck.ts: 删除 clearBinaryCache(零调用,binaryCache 仍由 isBinaryInstalled 使用)
- claudeAiLimits.ts: 删除 RATE_LIMIT_DISPLAY_NAMES 常量 + getRateLimitDisplayName(互为唯一消费者)
- codeIndexing.ts: 删除 detectCodeIndexingFromMcpTool(同胞 detectCodeIndexingFromCommand/McpServerName 仍活跃)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除多处仅内部使用的 export 关键字

下列符号均仅在本文件内被引用,export 关键字冗余;保留符号本体不动:

- internalLogging.ts: getContainerId(line 88 内部调用)
- api/errors.ts: isMediaSizeError(line 151 内部调用)
- api/withRetry.ts: parseMaxTokensContextOverflowError(line 389/724 内部调用)
- statsCache.ts: STATS_CACHE_VERSION(7 处内部使用)
- startupProfiler.ts: logStartupPerf(line 128 内部调用)
- bashCommandHelpers.ts: CommandIdentityCheckers(3 处内部参数类型)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 清理注释代码块与 legacy shim

注释代码(已死的、引用不存在符号的注释块):
- Onboarding.tsx: 注释化的 preflight if-block(引用不存在的 preflightStep)
- ultraplan.tsx: 两处引用不存在符号的注释(ULTRAPLAN_INSTRUCTIONS、getUltraplanModel)
- types/hooks.ts: 禁用的 type-fest IsEqual 类型断言块
- types/global.d.ts: 已被真实模块取代的 Ultraplan ambient declares
- types/textInputTypes.ts: 注释化的 onMessage interface 成员

legacy shim:
- cli/bg.ts: 删除 handleBgFlag 别名 export(同胞 handleBgStart 已被所有调用点使用)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 ccshareResume stub 及 main.tsx 的 ccshare fast-path

- 删除 src/utils/ccshareResume.ts(parseCcshareId 恒返回 null、loadCcshare 恒抛错的 stub)
- 同步移除 src/main.tsx 中 USER_TYPE === 'ant' 守卫下的 if (ccshareId) {...} else {...} 双分支
- 提升 else 块(文件路径 resume 处理)为直接进入 if (options.resume) 块内

ccshare 是 Anthropic 内部特性(go/ccshare URL),stub 未实现导致 ccshareId 恒为 null,整个 ccshare 分支永不进入;保留的文件路径 resume 路径不变。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 environment-runner stub 及其 cli.tsx fast-path

与 self-hosted-runner 相同模式的 sibling(工作流 1 verifier 建议同步处理):

- 删除 src/environment-runner/main.ts(自动生成的 Promise.resolve() stub)
- 同步移除 src/entrypoints/cli.tsx 中 feature('BYOC_ENVIRONMENT_RUNNER') 守卫的 fast-path 分支
- 清理两个空目录(src/self-hosted-runner/、src/environment-runner/)

BYOC_ENVIRONMENT_RUNNER flag 不在 build.ts DEFAULT_BUILD_FEATURES 也不在 dev 默认列表,所有默认配置下整段为构建期死代码。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 删除孤立诊断脚本 probe-local-wiring.ts

#!/usr/bin/env bun shebang 的手动诊断脚本,全仓零引用,不在 package.json/build.ts/vite.config.ts/CI workflows 中。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 ultrareview preflight stub 及其测试

- 删除 src/services/api/ultrareviewPreflight.ts(自动生成的 stub)
- 删除 src/commands/review/UltrareviewPreflightDialog.tsx(依赖前者的 UI stub)
- 删除 src/services/api/__tests__/ultrareviewPreflight.test.ts(测试已删代码)
- 同步移除 ultrareviewCommand.test.tsx 中对 UltrareviewPreflightDialog 的 mock

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 cachedMCConfig stub 及 prompts.ts 的 CACHED_MICROCOMPACT 死代码

- 删除 src/services/compact/cachedMCConfig.ts(自动生成的 stub)
- 同步移除 src/constants/prompts.ts 中依赖该 stub 的代码:
  - getCachedMCConfigForFRC 变量(feature('CACHED_MICROCOMPACT') 守卫的 require)
  - getFunctionResultClearingSection 函数(约 18 行)
  - systemPrompt 数组中的 frc section 调用与注册

CACHED_MICROCOMPACT 不在 build.ts DEFAULT_BUILD_FEATURES 也不在 dev 默认列表,所有默认配置下整段为构建期死代码。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 goalAudit stub 及其测试引用

- 删除 src/services/goal/goalAudit.ts(导出 COMPLETION_AUDIT_RULES/BLOCKED_AUDIT_RULES/isGoalTerminal 等未引用的 stub)
- 同步移除 tests/integration/goal-lifecycle.test.ts 中对 goalAudit 的 import 和一个测试用例(budget_limited is terminal)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 删除 agentSdkTypes 第二批 not-implemented stub

移除运行时函数体仅为 throw new Error 或 placeholder 的 stub:
- createSdkMcpToolDefinition、createSdkMcpServer
- query 函数重载与实现
- unstable_v2_* 系列函数
- session 操作 stub(getSessionMessages/listSessions/getSessionInfo/renameSession/tagSession/forkSession)
- AbortError 类

保留所有 export type 重导出和类型别名(仍是公共类型面)。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 Tool.ts 中 backwards-compat 重导出 shim

删除 "// Re-export progress types for backwards compatibility" 注释块及其重导出语句。所有消费方已直接从 src/types/tools.js 导入,无需重导出转发。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 bootstrap/state.ts 中 4 个未引用的 export

- clearRegisteredHooks(STATE.registeredHooks 仍由其他函数管理)
- getInvokedSkills(getInvokedSkillsForAgent 是活跃入口)
- getSessionSource(setSessionSource 仍活跃,sessionSource state 字段保留)
- markScrollActivity(scrollDraining/getIsScrollDraining/waitForScrollDrain 仍活跃)

仅删除孤儿访问器,不动模块级 state 副作用。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 src/ 下多处未引用的导出

涉及 18 个文件,每处均为独立的 unreferenced export 删除或 export 关键字冗余移除:

- bridge/bridgeStatusUtil.ts、components/TrustDialog/utils.ts、context/stats.tsx
- keybindings/loadUserBindings.ts、memdir/paths.ts、remote/sdkMessageAdapter.ts
- services/acp/utils.ts(删除 nodeToWebReadable,全仓零引用)
- services/api/metricsOptOut.ts、services/lsp/LSPDiagnosticRegistry.ts、services/lsp/manager.ts
- services/mcp/utils.ts、services/skillLearning/projectContext.ts
- services/teamMemorySync/secretScanner.ts、services/teamMemorySync/watcher.ts
- skills/loadSkillsDir.ts、utils/attachments.ts、utils/filePersistence/filePersistence.ts
- utils/messageQueueManager.ts

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 packages/ 下多处未引用的导出

涉及 11 个 workspace 包文件,每处均为独立的 unreferenced export 删除或 export 关键字冗余移除:

- @ant/ink/core/termio/csi.ts(eraseLine)
- acp-link/manager/types.ts、acp-link/ws-message.ts
- builtin-tools/AgentTool/agentMemory.ts、BashTool/bashSecurity.ts、BashTool/sedEditParser.ts
- builtin-tools/ConfigTool/supportedSettings.ts、FileEditTool/utils.ts
- remote-control-server/store.ts、transport/event-bus.ts、types/messages.ts

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* Revert "fix: 终端内容溢出 viewport 时的重影 bug"

This reverts commit 3d18e1da58.

* revert: 移除主屏幕周期性 self-healing 重绘

回退 f69c7051 中引入的 ink.tsx self-healing 机制(lastMainScreenHealTime 字段
+ 每 5 秒触发全量重绘 + needsEraseBeforePaint 主屏幕分支)。该机制在 workflow
面板持续刷新场景下表现为可见的"重复刷新",且修复效果不稳定。

alt-screen 的 needsEraseBeforePaint 路径和 prevFrameContaminated 字段保留,
它们仍服务于 handleResize / layout shift / selection 高亮。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix: /workflows 面板默认只显示运行中 run,根治 tab 行乱码

之前几次渲染层修复都失败,因为没动 tab 列表的数据源:打开 /workflows 会
自动 hydrate 最多 20 个历史 done/killed run,全部塞进一行 TabsBar,超出
终端宽度后 Ink 把字符画到屏外造成重影乱码。

- selectors.ts 加 filterActiveRuns(只留 status === 'running')和
  capTabsForDisplay(超额 fold 成 +N)两个 pure function
- WorkflowsPanel 接线 activeRuns:focus clamp、focused、nextTab/prevTab、
  TabsBar 全部基于过滤后的 activeRuns
- TabsBar 复用 truncateLabel 限制每个 tab 名 18 字符 + 最多 6 个 tab,
  多余显示 +N,从结构上钉死单行总宽度

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix: /workflows 面板 phase 状态在脚本省略 phase() 时显示错乱

ultracode canonical pipeline 脚本常在 agent() 直接传 opts.phase 而不调 phase()
hook,导致 phase_started 从未发出;同时 phase_done 只在下次 phase() 触发,上一
个 phase 在 run.phases 里一直停在 running。mergePhases 之前把 actual 当权威,
于是出现 "Map 8/8 全 done 还显示 running、Find 1/4 running 反而显示 pending"。

改为派生层修复:mergePhases 新增 derivePhaseStatus——actual.status==='done'
权威;否则有 agents 就按 agents 状态推(全 done→done,否则 running);否则看
actual 是否 running。再补一层遍历,让只在 agents 上出现的 phase 也进 sidebar。
不改 store 状态语义,已有 state.json 无需迁移。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* docs: 更新 readme

* fix: ACP 模式未读取 settings.local.json

entry.ts 在 ACP 握手期调用的 applySafeConfigEnvironmentVariables 触发了
loadSettingsFromDisk,此时 getOriginalCwd() 还是进程启动 cwd(非项目目录),
导致 localSettings/projectSettings 按错误路径解析为空并被 session cache 锁住,
后续 createSession 里 setOriginalCwd 也无法纠正。在 setOriginalCwd 与 chdir
之后清缓存并重新应用,让 settings.local.json 和项目级 env 对
readSettingsPermissionMode 及下游可见。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

---------

Co-authored-by: glm-5.2 <zai-org@claude-code-best.win>
2026-06-22 09:59:36 +08:00

289 lines
13 KiB
TypeScript

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';
import { getWorkflowService } from '../service.js';
import type { RunProgress } from '../progress/store.js';
import { AgentList } from './AgentList.js';
import { PhaseSidebar } from './PhaseSidebar.js';
import { TabsBar } from './TabsBar.js';
import { RUN_STATUS_COLOR, RUN_STATUS_TEXT } from './status.js';
import { type FocusColumn, type WorkflowKeyboardHandlers, useWorkflowKeyboard } from './useWorkflowKeyboard.js';
import { ALL_PHASE, filterActiveRuns, filterAgentsByPhase, formatDuration, mergePhases } from './selectors.js';
/**
* Clamp the selected index to a valid range (empty list -> 0; out of range -> last position; negative/NaN -> 0).
* Extracted into a module-level pure function: called inside the panel + unit tested for the same logic, to avoid behavior drift.
*/
export function clampSelected(selected: number, len: number): number {
if (len === 0) return 0;
const n = Math.trunc(selected);
if (Number.isNaN(n) || n < 0) return 0;
return Math.min(n, len - 1);
}
/**
* Determine whether the focused run completed the running -> terminal state transition (used for panel auto-exit).
* Extracted into a pure function for easy unit testing; called directly inside the panel's useEffect.
*
* Trigger condition: prev and curr are the same runId, prev is running, curr is completed/failed/killed.
* - Opening the history panel (prev=null): does not trigger
* - Switching to an already completed tab (different runId): does not trigger
* - Same run running -> terminal: triggers
*/
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 main panel: three-region focus model (top tab + left phase sidebar + right agent list).
*
* - useSyncExternalStore subscribes to WorkflowService (the store returns stable snapshots, no re-render without change).
* - Focus state: activeRunId / focusColumn('phases'|'agents') / selectedPhaseIndex(0=All) / selectedAgentIndex.
* - Keybindings: Tab switch run · Left/Right switch focus column · Up/Down move within column · x kill · r resume · q/Esc quit.
*/
export function WorkflowsPanel({
onDone,
context,
}: {
onDone: LocalJSXCommandOnDone;
context: LocalJSXCommandContext;
}): React.ReactNode {
const svc = getWorkflowService();
const runs = useSyncExternalStore(
svc.subscribe,
() => svc.listRuns(),
() => [],
);
// Only in-flight runs reach the tab row. Terminal (completed/failed/killed) runs are hidden so opening
// the panel no longer floods the row with persisted history (which overflowed the terminal and rendered
// garbled overlapping text). They stay on disk and remain resumable via getRunAsync.
const activeRuns = filterActiveRuns(runs);
const [activeRunId, setActiveRunId] = useState<string | null>(null);
const [focusColumn, setFocusColumn] = useState<FocusColumn>('phases');
const [selectedPhaseIndex, setSelectedPhaseIndex] = useState(0);
const [selectedAgentIndex, setSelectedAgentIndex] = useState(0);
// kill secondary confirmation. null = no dialog; 'workflow' = kill the whole run; 'agent' = kill the currently selected agent.
// When non-null the keyboard enters confirm mode (only y/Enter/n/Esc/q respond).
const [confirmKill, setConfirmKill] = useState<null | 'agent' | 'workflow'>(null);
// On mount, trigger a single disk scan to hydrate historical runs (the service's internal persistedLoaded flag guards idempotency).
// Re-mount / re-render does not scan again (guarded by the process-singleton flag). The svc reference is stable (getWorkflowService singleton).
useEffect(() => {
void svc.loadPersistedRuns();
}, [svc]);
// On activeRuns change: activeRunId invalidated (killed / first time) -> clamp to the first one.
// Tracks activeRuns (not raw runs) so focus never lands on a hidden terminal run.
useEffect(() => {
if (activeRuns.length === 0) {
if (activeRunId !== null) setActiveRunId(null);
return;
}
if (!activeRuns.some(r => r.runId === activeRunId)) {
setActiveRunId(activeRuns[0]!.runId);
}
}, [activeRuns, activeRunId]);
const focused: RunProgress | undefined = activeRuns.find(r => r.runId === activeRunId);
const phases = focused ? mergePhases(focused) : [];
// The sidebar includes the All row: prepend one item to the phases array -> total rows = phases.length + 1
const phaseRowCount = phases.length + 1;
const clampedPhase = clampSelected(selectedPhaseIndex, phaseRowCount);
// Auto-exit the panel when the focused run transitions from running to terminal (800ms delay so the user sees the ✓/✗ terminal state).
// Only triggered by a state transition on the same runId: switching to an already completed tab (prev was a different run) does not exit; opening the history panel
// (prev=null) does not exit either. Otherwise the agent is blocked by the panel while waiting for the Workflow tool result, and the user must press q manually.
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]);
// Selected phase title (0 = All = undefined)
const selectedPhaseTitle = clampedPhase === 0 ? undefined : phases[clampedPhase - 1]?.title;
const visibleAgents = focused ? filterAgentsByPhase(focused.agents, selectedPhaseTitle) : [];
const clampedAgent = clampSelected(selectedAgentIndex, visibleAgents.length);
const switchTab = (runId: string): void => {
setActiveRunId(runId);
setFocusColumn('phases');
setSelectedPhaseIndex(0);
setSelectedAgentIndex(0);
};
const nextTab = (): void => {
if (activeRuns.length === 0) return;
const idx = activeRuns.findIndex(r => r.runId === activeRunId);
const next = activeRuns[(idx + 1) % activeRuns.length]!;
switchTab(next.runId);
};
const prevTab = (): void => {
if (activeRuns.length === 0) return;
const idx = activeRuns.findIndex(r => r.runId === activeRunId);
const next = activeRuns[(idx - 1 + activeRuns.length) % activeRuns.length]!;
switchTab(next.runId);
};
const handlers: WorkflowKeyboardHandlers = {
nextTab,
prevTab,
focusLeft: () => setFocusColumn('phases'),
focusRight: () => setFocusColumn('agents'),
moveUp: () => {
if (focusColumn === 'phases') setSelectedPhaseIndex(s => clampSelected(s - 1, phaseRowCount));
else setSelectedAgentIndex(s => clampSelected(s - 1, visibleAgents.length));
},
moveDown: () => {
if (focusColumn === 'phases') setSelectedPhaseIndex(s => clampSelected(s + 1, phaseRowCount));
else setSelectedAgentIndex(s => clampSelected(s + 1, visibleAgents.length));
},
killAgent: () => {
// Only pop the agent confirmation when the agents column is focused (pressing x in the phases column has no target, no-op).
// The selected agent is decided by visibleAgents[clampedAgent]; saved into confirmKill and then
// actually executed by confirmYes - to avoid mis-killing caused by visibleAgents changing between two renders.
if (focusColumn !== 'agents' || !focused) return;
const agent = visibleAgents[clampedAgent];
if (!agent) return;
setConfirmKill('agent');
},
killWorkflow: () => {
if (!focused) return;
setConfirmKill('workflow');
},
resumeFocused: () => {
if (!focused) return;
const canUseTool = context.canUseTool;
if (!canUseTool) {
onDone('resume needs canUseTool context; run /<name> resume from the main session.');
return;
}
void svc
.launch({ resumeFromRunId: focused.runId, name: focused.workflowName }, context, canUseTool)
.catch(e => onDone(`resume failed: ${(e as Error).message}`));
},
newRun: () => onDone('Tip: start a named workflow with /<name>, or pass name via the Workflow tool.'),
quit: () => {
// In confirm mode q = cancel confirmation (routeWorkflowKey already routed to confirmNo);
// only in non-confirm mode does it really exit the panel.
if (confirmKill !== null) {
setConfirmKill(null);
return;
}
onDone();
},
confirmYes: () => {
if (confirmKill === 'workflow' && focused) {
svc.kill(focused.runId);
// After killing the entire workflow, immediately return to the main chat: the run_done event -> the store reducer changes the status to
// killed -> notifications.ts bridges enqueuePendingNotification, and the main chat shows
// `Workflow "<name>" was stopped`. Staying on the panel would instead make the user miss the "stopped" feedback.
setConfirmKill(null);
onDone();
return;
} else if (confirmKill === 'agent' && focused) {
const agent = visibleAgents[clampedAgent];
if (agent) svc.killAgent(focused.runId, agent.id);
}
setConfirmKill(null);
},
confirmNo: () => setConfirmKill(null),
};
useWorkflowKeyboard(handlers, confirmKill !== null ? 'confirm' : 'normal');
const running = runs.filter(r => r.status === 'running').length;
const done = runs.length - running;
const phaseHeader = selectedPhaseTitle ?? ALL_PHASE;
const agentDone = focused ? focused.agents.filter(a => a.status === 'done').length : 0;
// Refresh the header duration every second (shared clock; subscribing triggers re-render, duration follows wall clock).
const [clockRef] = useAnimationFrame(1000);
const elapsed = focused ? Date.now() - focused.startedAt : 0;
return (
<Box ref={clockRef} flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
<Box justifyContent="space-between">
<Text bold>{focused?.workflowName ?? 'Workflows'}</Text>
{focused ? (
<Text color="subtle">
{agentDone}/{focused.agentCount} agents · {formatDuration(elapsed)} ·{' '}
<Text color={RUN_STATUS_COLOR[focused.status] as keyof Theme}>{RUN_STATUS_TEXT[focused.status]}</Text>
</Text>
) : (
<Text color="subtle">
{running} running · {done} done
</Text>
)}
</Box>
{focused?.description ? <Text color="subtle">{focused.description}</Text> : null}
{activeRuns.length > 1 ? (
<Box marginTop={1}>
<TabsBar runs={activeRuns} activeRunId={activeRunId} />
</Box>
) : null}
<Box flexDirection="row" marginTop={1}>
<Box width="25%" flexDirection="column">
<Text color={focusColumn === 'phases' ? 'claude' : 'subtle'} bold>
Phases
</Text>
<PhaseSidebar
phases={phases}
agents={focused?.agents ?? []}
selectedIndex={clampedPhase}
focused={focusColumn === 'phases'}
/>
</Box>
<Text color="subtle"></Text>
<Box flexGrow={1} flexDirection="column">
<Text color={focusColumn === 'agents' ? 'claude' : 'subtle'} bold>
{phaseHeader} · {visibleAgents.length} agents
</Text>
<AgentList agents={visibleAgents} selectedIndex={clampedAgent} focused={focusColumn === 'agents'} />
</Box>
</Box>
<Box marginTop={1}>
<Text color="subtle">
{confirmKill !== null
? 'Confirm: y kill · n/Esc cancel'
: 'Tab switch run · ←/→ focus · ↑/↓ move · x kill agent · K kill workflow · r resume · q quit'}
</Text>
</Box>
{confirmKill !== null ? (
<Dialog
title={
confirmKill === 'workflow'
? `Kill workflow "${focused?.workflowName ?? ''}"?`
: `Kill agent "${visibleAgents[clampedAgent]?.label ?? ''}"?`
}
subtitle={
confirmKill === 'workflow'
? 'All in-flight agents will be aborted. Resume will replay from journal.'
: 'Only this agent aborts; other agents in the workflow keep running.'
}
onCancel={() => setConfirmKill(null)}
color="warning"
>
<Text color="subtle">Press y to confirm, or n/Esc to cancel.</Text>
</Dialog>
) : null}
</Box>
);
}