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>
This commit is contained in:
claude-code-best
2026-06-20 23:17:03 +08:00
parent ade9babee1
commit 1638728305
4 changed files with 154 additions and 18 deletions

View File

@@ -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)
})

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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)}`