mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-21 07:45:52 +00:00
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:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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)}`
|
||||
|
||||
Reference in New Issue
Block a user