From 16387283055a5c8ffa9c705c4278390c5d3315d8 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 20 Jun 2026 23:17:03 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20/workflows=20=E9=9D=A2=E6=9D=BF=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=8F=AA=E6=98=BE=E7=A4=BA=E8=BF=90=E8=A1=8C=E4=B8=AD?= =?UTF-8?q?=20run=EF=BC=8C=E6=A0=B9=E6=B2=BB=20tab=20=E8=A1=8C=E4=B9=B1?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前几次渲染层修复都失败,因为没动 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 --- src/workflow/__tests__/selectors.test.ts | 75 ++++++++++++++++++++++++ src/workflow/panel/TabsBar.tsx | 31 +++++++++- src/workflow/panel/WorkflowsPanel.tsx | 35 ++++++----- src/workflow/panel/selectors.ts | 31 ++++++++++ 4 files changed, 154 insertions(+), 18 deletions(-) diff --git a/src/workflow/__tests__/selectors.test.ts b/src/workflow/__tests__/selectors.test.ts index 72390032d..c67cc5d55 100644 --- a/src/workflow/__tests__/selectors.test.ts +++ b/src/workflow/__tests__/selectors.test.ts @@ -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, @@ -80,3 +82,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) +}) diff --git a/src/workflow/panel/TabsBar.tsx b/src/workflow/panel/TabsBar.tsx index 7f570b26d..af6064f81 100644 --- a/src/workflow/panel/TabsBar.tsx +++ b/src/workflow/panel/TabsBar.tsx @@ -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 (no runs); } + const { runs: visible, overflow } = capTabsForDisplay(runs, MAX_TABS); return ( - {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 ( @@ -32,6 +51,12 @@ export function TabsBar({ runs, activeRunId }: { runs: RunProgress[]; activeRunI ); })} + {overflow > 0 ? ( + + +{overflow} + + + ) : null} ); } diff --git a/src/workflow/panel/WorkflowsPanel.tsx b/src/workflow/panel/WorkflowsPanel.tsx index 87a8df03f..0e435eb84 100644 --- a/src/workflow/panel/WorkflowsPanel.tsx +++ b/src/workflow/panel/WorkflowsPanel.tsx @@ -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(null); const [focusColumn, setFocusColumn] = useState('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({ {focused?.description ? {focused.description} : null} - {runs.length > 1 ? ( + {activeRuns.length > 1 ? ( - + ) : null} diff --git a/src/workflow/panel/selectors.ts b/src/workflow/panel/selectors.ts index 606dfde81..0d18eddf5 100644 --- a/src/workflow/panel/selectors.ts +++ b/src/workflow/panel/selectors.ts @@ -54,6 +54,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)}`