mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-25 17:45:50 +00:00
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 commit3d18e1da58. * 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>
This commit is contained in:
@@ -2,6 +2,8 @@ import { expect, test } from 'bun:test'
|
||||
import type { AgentProgress, RunProgress } from '../progress/store.js'
|
||||
import {
|
||||
ALL_PHASE,
|
||||
capTabsForDisplay,
|
||||
filterActiveRuns,
|
||||
mergePhases,
|
||||
filterAgentsByPhase,
|
||||
tabLabel,
|
||||
@@ -61,6 +63,57 @@ test('mergePhases: actual but undeclared phase appended to the end', () => {
|
||||
expect(mergePhases(r).map(p => p.title)).toEqual(['Find', 'Adhoc'])
|
||||
})
|
||||
|
||||
// Regression: scripts that pass opts.phase directly to agent() without a phase() hook call
|
||||
// (the ultracode canonical pipeline pattern). phase_started is never emitted for those phases,
|
||||
// so run.phases lacks them. The sidebar used to show them as pending forever while agents were
|
||||
// clearly running under them — and worse, the previous phase stayed "running" because phase_done
|
||||
// only fires on the next phase() call. Derive status from agents when no actual record exists.
|
||||
test('mergePhases: derives status from agents when phase_started was never emitted', () => {
|
||||
// Mirrors the real .claude/workflow-runs/wnxct9u3q/script.js shape:
|
||||
// phase('Map') called, 8 Map agents done; pipeline stage with phase:'Find' running (1/4);
|
||||
// Verify / Synthesize declared but not started; phase('Synthesize') not yet reached so
|
||||
// phase_done Map has not fired either — actual Map is still 'running'.
|
||||
const r = run({
|
||||
declaredPhases: ['Map', 'Find', 'Verify', 'Synthesize'],
|
||||
phases: [{ title: 'Map', status: 'running' }],
|
||||
agents: [
|
||||
...Array.from({ length: 8 }, (_, i) => ({
|
||||
id: i,
|
||||
phase: 'Map',
|
||||
status: 'done' as const,
|
||||
resultKind: 'ok',
|
||||
})),
|
||||
{ id: 100, phase: 'Find', status: 'done', resultKind: 'ok' },
|
||||
{ id: 101, phase: 'Find', status: 'running' },
|
||||
{ id: 102, phase: 'Find', status: 'running' },
|
||||
{ id: 103, phase: 'Find', status: 'running' },
|
||||
],
|
||||
})
|
||||
expect(mergePhases(r)).toEqual([
|
||||
{ title: 'Map', status: 'done', done: 8, total: 8 },
|
||||
{ title: 'Find', status: 'running', done: 1, total: 4 },
|
||||
{ title: 'Verify', status: 'pending', done: 0, total: 0 },
|
||||
{ title: 'Synthesize', status: 'pending', done: 0, total: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
// A phase that appears only on agents (not in declaredPhases, not in run.phases) is still
|
||||
// surfaced so the user sees it in the sidebar.
|
||||
test('mergePhases: phase only present on agents is appended and derived from agent states', () => {
|
||||
const r = run({
|
||||
declaredPhases: ['Scan'],
|
||||
phases: [],
|
||||
agents: [
|
||||
{ id: 1, phase: 'AdhocFromAgent', status: 'running' },
|
||||
{ id: 2, phase: 'AdhocFromAgent', status: 'done', resultKind: 'ok' },
|
||||
],
|
||||
})
|
||||
expect(mergePhases(r)).toEqual([
|
||||
{ title: 'Scan', status: 'pending', done: 0, total: 0 },
|
||||
{ title: 'AdhocFromAgent', status: 'running', done: 1, total: 2 },
|
||||
])
|
||||
})
|
||||
|
||||
test('filterAgentsByPhase: All / undefined → all; specified → only that phase', () => {
|
||||
const agents: AgentProgress[] = [
|
||||
{ id: 1, phase: 'A', status: 'running' },
|
||||
@@ -80,3 +133,76 @@ test('filterAgentsByPhase: All / undefined → all; specified → only that phas
|
||||
test('tabLabel: workflow name + last 4 chars short code of runId', () => {
|
||||
expect(tabLabel('review-changes', 'wf_abc123def')).toBe('review-changes#3def')
|
||||
})
|
||||
|
||||
// filterActiveRuns: only running runs reach the panel's tab row. Done/killed/completed are hidden
|
||||
// so opening /workflows no longer floods the tab row with months of historical runs (caused
|
||||
// tab overflow → garbled render when total width exceeded the terminal).
|
||||
test('filterActiveRuns: only status === "running" survives; completed/failed/killed dropped', () => {
|
||||
const r1 = run({ runId: 'r1', status: 'running' })
|
||||
const r2 = run({ runId: 'r2', status: 'running' })
|
||||
const r3 = run({ runId: 'r3', status: 'completed' })
|
||||
const r4 = run({ runId: 'r4', status: 'failed' })
|
||||
const r5 = run({ runId: 'r5', status: 'killed' })
|
||||
expect(filterActiveRuns([r1, r2, r3, r4, r5])).toEqual([r1, r2])
|
||||
})
|
||||
|
||||
test('filterActiveRuns: empty input -> empty output', () => {
|
||||
expect(filterActiveRuns([])).toEqual([])
|
||||
})
|
||||
|
||||
test('filterActiveRuns: all terminal -> empty (panel falls back to "(no active runs)")', () => {
|
||||
expect(
|
||||
filterActiveRuns([run({ status: 'completed' }), run({ status: 'killed' })]),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('filterActiveRuns: preserves input order (no re-sort)', () => {
|
||||
const a = run({ runId: 'a', status: 'running', startedAt: 5 })
|
||||
const b = run({ runId: 'b', status: 'running', startedAt: 1 })
|
||||
expect(filterActiveRuns([a, b]).map(r => r.runId)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
// capTabsForDisplay: even if active runs somehow accumulate (long-lived sessions, runaway launcher),
|
||||
// the tab row must never overflow the terminal — cap at maxTabs, fold the remainder into a +N marker.
|
||||
test('capTabsForDisplay: under cap -> as-is', () => {
|
||||
const runs = [
|
||||
run({ runId: 'r1', status: 'running' }),
|
||||
run({ runId: 'r2', status: 'running' }),
|
||||
]
|
||||
expect(capTabsForDisplay(runs, 8)).toEqual({ runs, overflow: 0 })
|
||||
})
|
||||
|
||||
test('capTabsForDisplay: over cap -> first maxTabs runs + overflow count', () => {
|
||||
const runs = Array.from({ length: 10 }, (_, i) =>
|
||||
run({ runId: `r${i}`, status: 'running' }),
|
||||
)
|
||||
const capped = capTabsForDisplay(runs, 8)
|
||||
expect(capped.runs).toHaveLength(8)
|
||||
expect(capped.runs.map(r => r.runId)).toEqual([
|
||||
'r0',
|
||||
'r1',
|
||||
'r2',
|
||||
'r3',
|
||||
'r4',
|
||||
'r5',
|
||||
'r6',
|
||||
'r7',
|
||||
])
|
||||
expect(capped.overflow).toBe(2)
|
||||
})
|
||||
|
||||
test('capTabsForDisplay: exactly at cap -> no overflow', () => {
|
||||
const runs = Array.from({ length: 8 }, (_, i) =>
|
||||
run({ runId: `r${i}`, status: 'running' }),
|
||||
)
|
||||
const capped = capTabsForDisplay(runs, 8)
|
||||
expect(capped.runs).toHaveLength(8)
|
||||
expect(capped.overflow).toBe(0)
|
||||
})
|
||||
|
||||
test('capTabsForDisplay: maxTabs=0 -> all folded into overflow (degenerate but defined)', () => {
|
||||
const runs = [run({ runId: 'r1', status: 'running' })]
|
||||
const capped = capTabsForDisplay(runs, 0)
|
||||
expect(capped.runs).toEqual([])
|
||||
expect(capped.overflow).toBe(1)
|
||||
})
|
||||
|
||||
@@ -3,21 +3,40 @@ import { Box, Text } from '@anthropic/ink';
|
||||
import type { Theme } from '@anthropic/ink';
|
||||
import type { RunProgress } from '../progress/store.js';
|
||||
import { RUN_STATUS_COLOR, STATUS_DOT } from './status.js';
|
||||
import { tabLabel } from './selectors.js';
|
||||
import { capTabsForDisplay, tabLabel } from './selectors.js';
|
||||
import { truncateLabel } from './AgentList.js';
|
||||
|
||||
/**
|
||||
* Per-tab name width budget. Long workflow names truncate (keeping the `#xxxx` short-code suffix so
|
||||
* same-name runs stay distinguishable). Sized for a ~120-col terminal: ~6 tabs fit per row.
|
||||
*/
|
||||
const TAB_LABEL_MAX = 18;
|
||||
|
||||
/**
|
||||
* Hard ceiling on simultaneously rendered tabs. Defensive fallback: even if active runs accumulate
|
||||
* (long-lived session, runaway launcher), the row must never overflow the terminal width and
|
||||
* re-introduce the garbled overlapping render seen previously. Surplus runs are folded into `+N`.
|
||||
*/
|
||||
const MAX_TABS = 6;
|
||||
|
||||
/**
|
||||
* Top run tab row: one tab per run (status dot + name + #short code).
|
||||
* The current tab is highlighted with an orange ═ underline.
|
||||
*
|
||||
* Defenses against overflow:
|
||||
* - Per-tab name truncated via truncateLabel (keeps `#xxxx` suffix for disambiguation).
|
||||
* - Row capped at MAX_TABS; remainder rendered as a `+N` marker so total width is bounded.
|
||||
*/
|
||||
export function TabsBar({ runs, activeRunId }: { runs: RunProgress[]; activeRunId: string | null }): React.ReactNode {
|
||||
if (runs.length === 0) {
|
||||
return <Text color="subtle">(no runs)</Text>;
|
||||
}
|
||||
const { runs: visible, overflow } = capTabsForDisplay(runs, MAX_TABS);
|
||||
return (
|
||||
<Box>
|
||||
{runs.map(r => {
|
||||
{visible.map(r => {
|
||||
const active = r.runId === activeRunId;
|
||||
const label = tabLabel(r.workflowName, r.runId);
|
||||
const label = truncateLabel(tabLabel(r.workflowName, r.runId), TAB_LABEL_MAX);
|
||||
const underline = '═'.repeat(label.length + 2);
|
||||
return (
|
||||
<Box key={r.runId} flexDirection="column" marginRight={2}>
|
||||
@@ -32,6 +51,12 @@ export function TabsBar({ runs, activeRunId }: { runs: RunProgress[]; activeRunI
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{overflow > 0 ? (
|
||||
<Box flexDirection="column" marginRight={2}>
|
||||
<Text color="subtle">+{overflow}</Text>
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ 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, filterAgentsByPhase, formatDuration, mergePhases } from './selectors.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).
|
||||
@@ -61,6 +61,10 @@ export function WorkflowsPanel({
|
||||
() => 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');
|
||||
@@ -76,18 +80,19 @@ export function WorkflowsPanel({
|
||||
void svc.loadPersistedRuns();
|
||||
}, [svc]);
|
||||
|
||||
// On runs change: activeRunId invalidated (killed / first time) -> clamp to the first one
|
||||
// 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 (runs.length === 0) {
|
||||
if (activeRuns.length === 0) {
|
||||
if (activeRunId !== null) setActiveRunId(null);
|
||||
return;
|
||||
}
|
||||
if (!runs.some(r => r.runId === activeRunId)) {
|
||||
setActiveRunId(runs[0]!.runId);
|
||||
if (!activeRuns.some(r => r.runId === activeRunId)) {
|
||||
setActiveRunId(activeRuns[0]!.runId);
|
||||
}
|
||||
}, [runs, activeRunId]);
|
||||
}, [activeRuns, activeRunId]);
|
||||
|
||||
const focused: RunProgress | undefined = runs.find(r => r.runId === 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;
|
||||
@@ -122,15 +127,15 @@ export function WorkflowsPanel({
|
||||
};
|
||||
|
||||
const nextTab = (): void => {
|
||||
if (runs.length === 0) return;
|
||||
const idx = runs.findIndex(r => r.runId === activeRunId);
|
||||
const next = runs[(idx + 1) % runs.length]!;
|
||||
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 (runs.length === 0) return;
|
||||
const idx = runs.findIndex(r => r.runId === activeRunId);
|
||||
const next = runs[(idx - 1 + runs.length) % runs.length]!;
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -225,9 +230,9 @@ export function WorkflowsPanel({
|
||||
</Box>
|
||||
{focused?.description ? <Text color="subtle">{focused.description}</Text> : null}
|
||||
|
||||
{runs.length > 1 ? (
|
||||
{activeRuns.length > 1 ? (
|
||||
<Box marginTop={1}>
|
||||
<TabsBar runs={runs} activeRunId={activeRunId} />
|
||||
<TabsBar runs={activeRuns} activeRunId={activeRunId} />
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -13,9 +13,40 @@ export type MergedPhase = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge declaredPhases (declared by meta) and run.phases (actually running/done):
|
||||
* - Declared order takes priority; phases present in actual but not declared are appended at the end.
|
||||
* - No actual record -> pending; otherwise take the actual status.
|
||||
* Derive a phase's sidebar status from the actual record + the agents grouped under it.
|
||||
*
|
||||
* The actual record comes from `phase_started`/`phase_done` events. Scripts that follow the
|
||||
* ultracode canonical pipeline pattern pass `opts.phase` directly to `agent()` inside
|
||||
* `pipeline()`/`parallel()` stages and never call `phase()` for those phases — so no
|
||||
* `phase_started` ever fires and `run.phases` lacks them. Worse, because `phase_done` only
|
||||
* emits when the *next* `phase()` runs, the previous phase stays "running" in `run.phases`
|
||||
* even after all its agents finish.
|
||||
*
|
||||
* Rules (checked in order):
|
||||
* 1. `phase_done` already fired → done is authoritative, respect it.
|
||||
* 2. Agents exist under this phase → derive from their states
|
||||
* (all done → done; otherwise → running). This is what the user actually sees.
|
||||
* 3. No agents yet → fall back to the actual record
|
||||
* (`running` if `phase()` was called and is still active, else pending).
|
||||
*/
|
||||
function derivePhaseStatus(
|
||||
actual: { status: 'running' | 'done' } | undefined,
|
||||
inPhase: AgentProgress[],
|
||||
): PhaseStatus {
|
||||
if (actual?.status === 'done') return 'done'
|
||||
if (inPhase.length > 0) {
|
||||
return inPhase.every(a => a.status === 'done') ? 'done' : 'running'
|
||||
}
|
||||
return actual?.status === 'running' ? 'running' : 'pending'
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge declaredPhases (declared by meta), run.phases (actually running/done),
|
||||
* and phases that appear only on agents:
|
||||
* - Declared order takes priority; then actual-but-undeclared; then agent-only phases.
|
||||
* Agent-only phases surface in the sidebar even when the script never called `phase()`
|
||||
* for them — otherwise the user sees agents running under a phase that isn't listed.
|
||||
* - Status is derived via {@link derivePhaseStatus}.
|
||||
* - done/total = done under that phase / total agents under that phase.
|
||||
*/
|
||||
export function mergePhases(
|
||||
@@ -28,17 +59,22 @@ export function mergePhases(
|
||||
if (seen.has(title)) return
|
||||
seen.add(title)
|
||||
const actual = actualByTitle.get(title)
|
||||
const status: PhaseStatus = !actual ? 'pending' : actual.status
|
||||
const inPhase = run.agents.filter(a => a.phase === title)
|
||||
out.push({
|
||||
title,
|
||||
status,
|
||||
status: derivePhaseStatus(actual, inPhase),
|
||||
done: inPhase.filter(a => a.status === 'done').length,
|
||||
total: inPhase.length,
|
||||
})
|
||||
}
|
||||
for (const t of run.declaredPhases) push(t)
|
||||
for (const p of run.phases) push(p.title)
|
||||
// Scripts that pass opts.phase directly to agent() (the ultracode pipeline pattern)
|
||||
// may have agents grouped under phases that never got a phase() call — surface them
|
||||
// so the sidebar reflects every phase the user can actually observe agents running in.
|
||||
for (const a of run.agents) {
|
||||
if (a.phase) push(a.phase)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -54,6 +90,37 @@ export function filterAgentsByPhase(
|
||||
return agents.filter(a => a.phase === selectedPhase)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only runs still in flight. The /workflows panel defaults to this view: opening the panel
|
||||
* no longer floods the tab row with months of persisted historical runs (which overflowed the
|
||||
* terminal width and produced garbled overlapping text). Terminal runs (completed/failed/killed)
|
||||
* stay on disk and remain resumable via getRunAsync; only the tab row filters them out.
|
||||
*
|
||||
* Pure + order-preserving: callers rely on the same relative order as the input (store.list()
|
||||
* already returns newest-first by updatedAt).
|
||||
*/
|
||||
export function filterActiveRuns(runs: RunProgress[]): RunProgress[] {
|
||||
return runs.filter(r => r.status === 'running')
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap how many runs reach the tab row. Defensive fallback: even if active runs accumulate
|
||||
* (long-lived session, runaway launcher), the row must never overflow the terminal width and
|
||||
* re-introduce the garbled render. Anything past maxTabs is folded into an `overflow` count
|
||||
* that the panel renders as `+N`.
|
||||
*
|
||||
* `runs` is sliced as-is (no re-sort); the caller is expected to have already applied
|
||||
* filterActiveRuns and any ordering upstream.
|
||||
*/
|
||||
export function capTabsForDisplay(
|
||||
runs: RunProgress[],
|
||||
maxTabs: number,
|
||||
): { runs: RunProgress[]; overflow: number } {
|
||||
const cap = Math.max(0, Math.trunc(maxTabs))
|
||||
const visible = runs.slice(0, cap)
|
||||
return { runs: visible, overflow: Math.max(0, runs.length - visible.length) }
|
||||
}
|
||||
|
||||
/** tab label: workflow name + `#` + last 4 chars of runId (disambiguates same-name runs). */
|
||||
export function tabLabel(workflowName: string, runId: string): string {
|
||||
return `${workflowName}#${runId.slice(-4)}`
|
||||
|
||||
Reference in New Issue
Block a user