fix(dashboard): card timer shows total execution = timed events + workflow runtime

The task card timer chip previously fell back through several metrics
(timed duration → workflow runtime → wallclock), so cards showed only a
subset of execution time. For FN-2714 this rendered <1m on the card while
the stats tab reported >2m of workflow runtime.

The chip now reports the sum of [timing]-tagged log events and workflow
step runtime (matching the new "Total execution time" metric in the stats
panel), with live elapsed for in-progress workflow steps. When neither
metric is recorded, the chip is hidden rather than falling back to
wallclock.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fusion
2026-04-27 14:23:54 -07:00
committed by gsxdsm
parent 3e43477dc8
commit 9eef425525
9 changed files with 921 additions and 412 deletions

View File

@@ -166,8 +166,6 @@ function SplashScreen({ loadingStatus }: { loadingStatus: string }) {
// ── Mini inline logo (header) ─────────────────────────────────────────────────
function MiniLogo() {
// flexShrink={0} keeps FUSION from being squeezed (and wrapping) when the
// header row's other children push past the terminal width.
return (
<Box flexDirection="row" gap={0} flexShrink={0}>
<Text color="cyanBright" bold wrap="truncate-end">FUSION</Text>
@@ -496,12 +494,12 @@ function LogsPanel({
) : entries.length !== state.logEntries.length && entries.length === 0 ? (
<Text dimColor>No entries match filter {logsSeverityFilter.toUpperCase()}.</Text>
) : (
<Box flexDirection="column">
<Box flexDirection="row" gap={1} marginBottom={0}>
<Text dimColor>[w] wrap {logsWrapEnabled ? "on" : "off"}</Text>
<Text dimColor>[f] {logsSeverityFilter}</Text>
{hiddenAbove > 0 && <Text dimColor> {hiddenAbove} more</Text>}
{hiddenBelow > 0 && <Text dimColor> {hiddenBelow} more</Text>}
<Box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
<Box height={1} flexDirection="row" gap={1} marginBottom={0} flexShrink={0} overflow="hidden">
<Text wrap="truncate-end" dimColor>[w] wrap {logsWrapEnabled ? "on" : "off"}</Text>
<Text wrap="truncate-end" dimColor>[f] {logsSeverityFilter}</Text>
{hiddenAbove > 0 && <Text wrap="truncate-end" dimColor> {hiddenAbove} more</Text>}
{hiddenBelow > 0 && <Text wrap="truncate-end" dimColor> {hiddenBelow} more</Text>}
</Box>
{visibleEntries.map((entry, displayIdx) => {
const absoluteIndex = visibleStart + displayIdx;
@@ -512,43 +510,60 @@ function LogsPanel({
const lvlColor = entry.level === "error" ? "red" : entry.level === "warn" ? "yellow" : "green";
const marker = isSelected ? "▶ " : " ";
// Wrap each entry in a height-pinned Box (when wrap is off) so a
// single entry can never grow beyond 1 row. Without this, certain
// narrow widths can cause Ink/Yoga to measure the nested-Text
// entry as taller than 1 row even with truncate-end set, which
// pushes the panel intrinsic height past the slot and scrolls
// the outer header off the top of the alt-screen.
const entryHeight = logsWrapEnabled ? undefined : 1;
if (isNarrow) {
const idx = narrowTimestamp(absoluteIndex);
const pfx = narrowPrefix(entry.prefix, NARROW_PREFIX_WIDTH);
return (
<Text
<Box
key={`${entry.timestamp.getTime()}-${displayIdx}`}
backgroundColor={bg}
wrap={logsWrapEnabled ? "wrap" : "truncate-end"}
height={entryHeight}
flexShrink={0}
overflow="hidden"
>
<Text color={isSelected ? "white" : "gray"} bold={isSelected}>{marker}</Text>
<Text color={fg} dimColor={!isSelected}>{idx} </Text>
<Text color={lvlColor}>{lvl}</Text>
<Text color={fg} dimColor={!isSelected}>{` ${pfx} `}</Text>
<Text color={fg} bold={isSelected}>{entry.message}</Text>
</Text>
<Text
backgroundColor={bg}
wrap={logsWrapEnabled ? "wrap" : "truncate-end"}
>
<Text color={isSelected ? "white" : "gray"} bold={isSelected}>{marker}</Text>
<Text color={fg} dimColor={!isSelected}>{idx} </Text>
<Text color={lvlColor}>{lvl}</Text>
<Text color={fg} dimColor={!isSelected}>{` ${pfx} `}</Text>
<Text color={fg} bold={isSelected}>{entry.message}</Text>
</Text>
</Box>
);
}
const ts = formatTimestamp(entry.timestamp);
// Pad/truncate prefix to a fixed slot so message column aligns
// across rows (rows without a prefix get blank padding instead of
// collapsing).
const prefixSlot = entry.prefix
? `[${entry.prefix}]`.slice(0, PREFIX_WIDTH).padEnd(PREFIX_WIDTH)
: " ".repeat(PREFIX_WIDTH);
return (
<Text
<Box
key={`${entry.timestamp.getTime()}-${displayIdx}`}
backgroundColor={bg}
wrap={logsWrapEnabled ? "wrap" : "truncate-end"}
height={entryHeight}
flexShrink={0}
overflow="hidden"
>
<Text color={isSelected ? "white" : "gray"} bold={isSelected}>{marker}</Text>
<Text color={fg} dimColor={!isSelected}>{ts} </Text>
<Text color={lvlColor}>{lvl}</Text>
<Text color={fg} dimColor={!isSelected}>{` ${prefixSlot} `}</Text>
<Text color={fg} bold={isSelected}>{entry.message}</Text>
</Text>
<Text
backgroundColor={bg}
wrap={logsWrapEnabled ? "wrap" : "truncate-end"}
>
<Text color={isSelected ? "white" : "gray"} bold={isSelected}>{marker}</Text>
<Text color={fg} dimColor={!isSelected}>{ts} </Text>
<Text color={lvlColor}>{lvl}</Text>
<Text color={fg} dimColor={!isSelected}>{` ${prefixSlot} `}</Text>
<Text color={fg} bold={isSelected}>{entry.message}</Text>
</Text>
</Box>
);
})}
</Box>
@@ -599,11 +614,11 @@ function UtilitiesPanel({ state, isFocused }: { state: DashboardState; isFocused
];
return (
<Panel title="Utilities" isFocused={isFocused} flexGrow={1}>
<Box flexDirection="column">
<Box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
{actions.map((action) => (
<Box key={action.key} flexDirection="row" gap={1}>
<Box key={action.key} flexDirection="row" gap={1} flexShrink={0}>
<Text color="yellow">[{action.key}]</Text>
<Text>{action.label}</Text>
<Text wrap="truncate-end">{action.label}</Text>
</Box>
))}
</Box>
@@ -620,7 +635,7 @@ function HelpOverlay() {
["[a]", "Agents view"],
["[g]", "Settings view"],
["[t]", "Git view"],
["[e]", "Explorer (file browser)"],
["[f]", "Files (when not on Logs); cycles log severity filter on Logs"],
["[Tab]", "Cycle focused panel / pane forward"],
["[Shift+Tab]", "Cycle focused panel / pane backward"],
["[1-5]", "Jump to panel (Main: System/Logs/Utilities/Stats/Settings)"],
@@ -637,7 +652,6 @@ function HelpOverlay() {
["[Enter/Space]", "Expand log entry (Logs)"],
["[c]", "Copy selected log entry to clipboard (Logs)"],
["[w]", "Toggle word wrap (Logs / Files)"],
["[f]", "Cycle severity filter (Main, any panel)"],
["[Space]", "Toggle boolean (Settings)"],
["[+/-]", "Adjust number (Settings)"],
["[p]", "Project picker (Board, Files)"],
@@ -691,11 +705,10 @@ function StatusModeGrid({
const { stdout } = useStdout();
const rows = stdout?.rows ?? 24;
const cols = stdout?.columns ?? 80;
// Top row: System (narrow) + Logs (wide).
// Bottom row: Stats + Utilities + Settings, all the same fixed height so
// they line up. ~8 rows fits Stats (4 stat rows + chrome 3) and a few
// utility/settings entries — anything more is clipped.
const middleHeight = Math.max(1, rows - 2);
// Middle area = rows - header(1) - body marginTop(1) - statusbar(1) = rows-3.
// Top of middle: System (short, intrinsic) + Logs (fills).
// Bottom of middle: Stats + Utilities + Settings, equal-width.
const middleHeight = Math.max(1, rows - 3);
const bottomShare = Math.min(10, Math.max(6, Math.floor(middleHeight * 0.35)));
const topShare = Math.max(1, middleHeight - bottomShare);
// LogsPanel chrome: border 2 + title 1 + filter 1 = 4.
@@ -705,20 +718,23 @@ function StatusModeGrid({
return (
<Box flexDirection="column" flexGrow={1}>
<Box flexDirection="column" flexGrow={1} overflow="hidden">
{/* System: full width, short height (border 2 + 1-2 wrapped content rows). */}
<Box flexShrink={0} overflow="hidden">
{/* System: full width, short height. flexShrink=2 so it collapses
faster than Logs when vertical space is tight. */}
<Box flexShrink={2} overflow="hidden">
<SystemPanel state={state} isFocused={focused === "system"} />
</Box>
{/* Logs: fills remaining vertical space. */}
<Box flexGrow={1} flexShrink={1} flexDirection="column" overflow="hidden">
{/* Logs: fills remaining vertical space. flexShrink=0 so System and
the bottom row collapse first — Logs keeps its space. */}
<Box flexGrow={1} flexShrink={0} flexDirection="column" overflow="hidden">
<LogsPanel
state={state}
isFocused={focused === "logs"}
availableRows={logsAvailableRows}
/>
</Box>
{/* Bottom row: Stats + Utilities + Settings, equal-width. */}
<Box flexDirection="row" flexShrink={0} overflow="hidden">
{/* Bottom row: Stats + Utilities + Settings, equal-width. flexShrink=2
so when terminal height is small they collapse before Logs does. */}
<Box flexDirection="row" flexShrink={2} overflow="hidden">
<Box flexDirection="column" flexGrow={1} flexBasis={0} overflow="hidden">
<StatsPanel state={state} isFocused={focused === "stats"} />
</Box>
@@ -749,10 +765,12 @@ function StatusModeSingle({
const { stdout } = useStdout();
const rows = stdout?.rows ?? 24;
const cols = stdout?.columns ?? 80;
// LogsPanel's row budget needs an explicit cap so it doesn't try to render
// more entries than will fit. Header(1) + StatusBar(1) + Panel chrome(3)
// + filter row(1) = 6.
const logsAvailableRows = Math.max(1, rows - 6);
// LogsPanel's row budget an explicit cap so it doesn't try to render
// more entries than will fit. Chrome accounting:
// header(1) + body marginTop(1) + statusbar(1) +
// panel border top(1) + panel title(1) + panel border bottom(1) +
// filter row(1) = 7.
const logsAvailableRows = Math.max(1, rows - 7);
tuiDebug("StatusModeSingle", { cols, rows, logsAvailableRows, focused });
const activePanel = () => {
@@ -818,16 +836,6 @@ function MainHeader({ state }: { state: DashboardState }) {
const interactiveView = state.interactiveView;
const { stdout } = useStdout();
const cols = stdout?.columns ?? 80;
const rows = stdout?.rows ?? 24;
tuiDebug("MainHeader", {
cols,
rows,
mode: state.mode,
view: state.interactiveView,
activeSection: state.activeSection,
});
// Single unified tab strip. "Main" is the status mode; the rest are
// interactive views. Active key matches the current mode/view.
type Tab =
| { key: string; label: string; kind: "main" }
| { key: string; label: string; kind: "interactive"; view: InteractiveView };
@@ -837,18 +845,8 @@ function MainHeader({ state }: { state: DashboardState }) {
{ key: "a", label: "Agents", kind: "interactive", view: "agents" },
{ key: "g", label: "Settings", kind: "interactive", view: "settings" },
{ key: "t", label: "Git", kind: "interactive", view: "git" },
{ key: "e", label: "Explorer", kind: "interactive", view: "files" },
{ key: "f", label: "Files", kind: "interactive", view: "files" },
];
// Don't gate header rendering on stdout.rows — tmux pane switches and
// other resize events can briefly report stale or zero dimensions, and a
// transient `return null` orphans the header on the next layout pass.
// Always render; Yoga/overflow:hidden handles the extreme cases.
// Width tiers, measured against actual rendered content with 6 tabs:
// * Full + help (cols >= 110): "[k] Label" tabs + help/quit hint.
// * Full (90-109): "[k] Label" tabs, no help hint (~85 chars).
// * Compact (50-89): "[k]" glyphs only — every shortcut still
// visible, just no labels (~45 chars).
// * Tiny (< 50): just FUSION + the active tab pill.
const showHelpHint = cols >= 110;
const fullLabels = cols >= 90;
const tiny = cols < 50;
@@ -856,7 +854,6 @@ function MainHeader({ state }: { state: DashboardState }) {
const isActive = (t: Tab) =>
t.kind === "main" ? !inInteractive : inInteractive && t.view === interactiveView;
if (tiny) {
// Just FUSION + the active tab pill. Inactive shortcuts dropped here.
const active = tabs.find(isActive);
return (
<Box height={1} flexDirection="row" gap={1} paddingX={1} flexShrink={0} overflow="hidden">
@@ -869,13 +866,6 @@ function MainHeader({ state }: { state: DashboardState }) {
</Box>
);
}
// height={1} hard-caps the header at a single row. Without this, at
// certain boundary widths the default Text wrap="wrap" on a tab whose
// content lands one column past the parent width would push a second
// row, making the header 2 rows tall — which in turn makes the whole
// frame exceed terminal rows, so Ink pushes the top of the layout
// off-screen. Combined with wrap="truncate-end" on every tab Text
// below, both axes are protected against single-column overflow.
return (
<Box height={1} flexDirection="row" gap={1} paddingX={1} paddingY={0} flexShrink={0} overflow="hidden">
<MiniLogo />
@@ -3851,13 +3841,17 @@ export function DashboardApp({ controller }: DashboardAppProps) {
controller.setInteractiveView("settings");
return;
}
// Logs severity filter — works any time the logs panel is visible
// (status mode). Cycles all → info → warn → error → all.
// 'f' is overloaded:
// * status mode + Logs panel focused → cycle severity filter
// * everywhere else → switch to Files view (interactive)
if (input === "f" || input === "F") {
if (state.mode === "status") {
if (state.mode === "status" && state.activeSection === "logs") {
controller.cycleSeverityFilter();
return;
}
controller.setMode("interactive");
controller.setInteractiveView("files");
return;
}
if (input === "t" || input === "T") {
@@ -3866,12 +3860,6 @@ export function DashboardApp({ controller }: DashboardAppProps) {
return;
}
if (input === "e" || input === "E") {
controller.setMode("interactive");
controller.setInteractiveView("files");
return;
}
// 'm' / 's' (alias) — switch to Main (status mode). Lowercase only;
// capital S/M are reserved for vim-style "jump to end" semantics.
if (input === "m" || input === "s") {
@@ -4066,30 +4054,29 @@ export function DashboardApp({ controller }: DashboardAppProps) {
hasSystemInfo: Boolean(state.systemInfo),
});
// Pin explicit heights on both children so the layout is fully deterministic
// and Yoga has no freedom to redistribute rows. Header always occupies row 0;
// the body fills rows 1..(rows-1). With overflow:hidden everywhere, content
// that exceeds its slot is clipped — the header can't be pushed off.
const headerHeight = 1;
const bodyHeight = Math.max(0, rows - headerHeight);
// Use flex-column natural placement (matches what Board/InteractiveMode
// does — that layout has always worked). Header takes its intrinsic 1
// row; body fills the rest via flexGrow=1.
//
// CRITICAL: marginTop={1} on the body. At narrow widths with Logs or
// Utilities active, Yoga's flex-column was placing the panel border at
// y=0 (same row as the header), causing the body to overdraw the header.
// The 1-row top margin guarantees panel content starts on row 1
// regardless of any Yoga edge-case at certain widths. Net cost: 1 row
// of vertical space (so the panel area is rows-2 instead of rows-1),
// but the header is always visible.
return (
<Box key={layoutKey} flexDirection="column" height={rows} width={cols} overflow="hidden">
<Box
height={headerHeight}
width={cols}
flexShrink={0}
flexGrow={0}
flexDirection="row"
overflow="hidden"
>
{/* Header: explicit height={1} so the wrapper always reserves row 0,
even at narrow widths where MainHeader's intrinsic height could
(in some Yoga edge cases) collapse to 0. */}
<Box height={1} width={cols} flexShrink={0} flexGrow={0} flexDirection="row" overflow="hidden">
<MainHeader state={state} />
</Box>
<Box
height={bodyHeight}
width={cols}
flexShrink={0}
flexGrow={0}
flexGrow={1}
flexShrink={1}
marginTop={1}
flexDirection="column"
overflow="hidden"
>

View File

@@ -402,8 +402,17 @@ export class DashboardTUI {
// ── State helpers called from Ink App ────────────────────────────────────
setActiveSection(section: SectionId): void {
const changed = this.activeSection !== section;
this.activeSection = section;
this.showHelp = false;
if (changed) {
// Brute-force: wipe alt-screen + reset Ink's log-update tracking before
// rendering the new section. Some sections (Logs, Utilities) appear to
// leave residual state in tmux that scrolls the header off the top on
// the first render after switching. A full recover here is overkill on
// every switch but eliminates the artifact.
this.recoverFrameNow();
}
this.notify();
}
@@ -442,6 +451,7 @@ export class DashboardTUI {
const idx = SECTION_ORDER.indexOf(this.activeSection);
this.activeSection = SECTION_ORDER[(idx + direction + SECTION_ORDER.length) % SECTION_ORDER.length];
this.showHelp = false;
this.recoverFrameNow();
this.notify();
}
@@ -637,6 +647,16 @@ export class DashboardTUI {
// visible at the bottom. \x1b[2J\x1b[H wipes the buffer first.
// Order: wipe → reset Ink's tracking → record dims → notify so React
// reads fresh dims and rerenders cleanly.
// Public version: callable from state-changing methods (e.g. switching
// panels). Reads current dims from process.stdout and runs the same
// wipe + ink-clear sequence as the resize-driven path.
recoverFrameNow(): void {
const cols = process.stdout?.columns ?? 0;
const rows = process.stdout?.rows ?? 0;
if (cols <= 0 || rows <= 0) return;
this.recoverFrame(cols, rows);
}
private recoverFrame(cols: number, rows: number): void {
tuiDebug("recoverFrame", {
cols,