fabro/apps/fabro-web/app/components/run-waterfall.tsx
fabro-sh-0530[bot] b196a97ac4
Introduce approved effect hooks and migrate direct useEffect calls (#425)
## Summary

Implements the React Effects Policy by creating the approved hook
surface in `hooks/effects.ts` and migrating a broad set of direct
`useEffect` calls across the codebase to either purpose-named hooks or
non-effect patterns.

### Plan Summary

- Add `hooks/effects.ts` exporting `useMountEffect`, `useInterval`,
`useTimeout`, `useDebouncedValue`, `useWindowEvent`, `useDocumentEvent`,
`useDocumentTitle`, `useMediaQuery`, `useLocationHash`, and
`useResizeObserver`
- Extract large imperative effects into purpose-named hooks:
`useTerminalSession`, `useFloatingTooltipMeasurements`,
`useAnnotatedRunGraphSvg`, `useInstallEffects`, and others
- Move install session fetch from a component effect into a SWR query
(`install-query.ts`)
- Replace `useEffect` + `useState` state-derivation patterns with
render-time computation or ref callbacks
- Replace `AskFabroLayoutProvider`/`useAskFabroLayout` context with a
prop callback

## What changed and why

**`hooks/effects.ts`** — the new approved primitive surface. All
internal `useEffect` calls here are intentional; the hooks expose the
*external system* they manage rather than leaking `useEffect` to
component code. `useMediaQuery` and `useLocationHash` use
`useSyncExternalStore` instead of effect + state.

**`useTerminalSession`** — the largest extraction. The 130-line
xterm/WebSocket/ResizeObserver setup block moves from
`terminal-view.tsx` into its own hook, which now owns the `terminalRef`,
`fitRef`, and `socketRef` that previously cluttered the component.
`TerminalConnectionError` and `ConnectionStatus` types are exported from
the hook.

**`useFloatingTooltipMeasurements`** — extracts the `useLayoutEffect` +
ResizeObserver + window resize listener out of `FloatingTooltip`. The
`FloatingTooltipSize` type moves with it so consumers don't need to
import from the component.

**`useInstallSessionQuery` + `useInstallEffects`** — the install session
fetch moves from a component effect to SWR (`install-query.ts`). The
three remaining install effects (token URL scrubbing, GitHub error URL
scrubbing, health-poll restart) move into
`hooks/use-install-effects.ts`. The root-redirect effect is replaced
with a render-time `<Navigate>` gate. The `SessionState` discriminant
now carries `token` so stale query results can be discarded without an
effect chain.

**`SelectionCheckbox`** — `useEffect` setting `input.indeterminate` is
replaced with a ref callback, which runs synchronously after the node is
attached and avoids a stale-frame flash.

**`event-debug.tsx`** — the manual `window.addEventListener("keydown",
...)` pattern is replaced with `useWindowEvent`, removing the
`react-doctor-disable` suppression comments.

**`run-waterfall.tsx`** — the local `useTickingNow` is deleted;
`RunWaterfall` now calls the shared `useTickingNow` from `lib/time` with
the new `active` parameter signature.

**`toast.test.tsx`** — `useEffect(() => onReady?.(api), ...)` in the
test helper is replaced with a direct call during render, which is valid
because `onReady` has no side effects that React cares about.

**`AskFabroSidebar`** — `setIsResizing` from the layout context is
replaced with an `onResizeActiveChange` prop, removing the
`useAskFabroLayout` call and the hidden context coupling from the
sidebar.


### Fabro Details

<details>
<summary>Ran 3 stages in 114m 5s for $95.71</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| work | 103m 3s | $80.42 | 0 |
| audit | 10m 19s | $15.29 | 0 |
| **Total** | **114m 5s** | **$95.71** | **0** |

</details>

<details>
<summary>Ran <code>Goal.fabro</code> (4 nodes and 5 edges)</summary>

```dot
digraph Goal {
    graph [
        goal="Complete the user-provided goal",
        rankdir=LR,
        max_node_visits=30
    ]

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    work [
        label="Work",
        thread_id="goal",
        fidelity="full",
        max_visits=12,
        model="gpt-55",
        reasoning_effort="xhigh",
        prompt="@prompts/continue.md"
    ]

    audit [
        label="Completion Audit",
        thread_id="goal",
        fidelity="full",
        goal_gate=true,
        retry_target="work",
        output_schema="routing",
        output_retries=2,
        max_visits=12,
        model="gpt-55",
        reasoning_effort="xhigh",
        prompt="@prompts/audit.md"
    ]

    start -> work -> audit

    audit -> exit [label="Done", condition="outcome=succeeded"]
    audit -> work [label="Continue", condition="outcome=failed || preferred_label=Continue"]
    audit -> work [label="No clear verdict"]
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
2026-05-27 10:37:29 -04:00

324 lines
9.3 KiB
TypeScript

import { useMemo, type ReactNode } from "react";
import { Link } from "react-router";
import { StageState, type RunStage } from "@qltysh/fabro-api-client";
import { HoverCard, PopoverHeader, PopoverRow, PopoverRows } from "./ui";
import { isVisibleStage } from "../data/runs";
import { formatAbsoluteTs, formatDurationMs } from "../lib/format";
import {
formatStageLabel,
stageStatusLabel,
stageStatusTone,
} from "../lib/stage-sidebar";
import { deriveRunPhases, type RunPhase } from "../lib/run-phases";
import { useTickingNow } from "../lib/time";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
interface WaterfallProps {
runId: string;
events: EventEnvelope[];
stages: RunStage[];
createdAtIso: string;
completedAtIso: string | null;
}
interface Row {
key: string;
kind: "phase" | "stage";
label: string;
startMs: number;
endMs: number | null;
durationMs: number | null;
barClass: string;
href: string | null;
popover: ReactNode;
}
const MIN_BAR_WIDTH_PCT = 0.4;
function stageBarClass(status: StageState): string {
switch (status) {
case StageState.RUNNING:
case StageState.RETRYING:
return "bg-teal-500 animate-pulse";
case StageState.SUCCEEDED:
return "bg-mint";
case StageState.PARTIALLY_SUCCEEDED:
return "bg-amber";
case StageState.FAILED:
return "bg-coral";
case StageState.PENDING:
return "bg-overlay-strong";
case StageState.SKIPPED:
case StageState.CANCELLED:
return "bg-fg-muted/40";
}
}
function isStageInFlight(status: StageState): boolean {
return status === StageState.RUNNING || status === StageState.RETRYING;
}
function chooseTickIntervalMs(rangeMs: number): number {
if (rangeMs < 30_000) return 5_000;
if (rangeMs < 120_000) return 15_000;
if (rangeMs < 600_000) return 60_000;
if (rangeMs < 3_600_000) return 5 * 60_000;
if (rangeMs < 6 * 3_600_000) return 30 * 60_000;
return 60 * 60_000;
}
function phasePopover(phase: RunPhase, durationMs: number | null, inFlight: boolean): ReactNode {
return (
<>
<PopoverHeader>{phase.label}</PopoverHeader>
<PopoverRows>
<PopoverRow label="Started">
{formatAbsoluteTs(new Date(phase.startMs).toISOString())}
</PopoverRow>
<PopoverRow label={inFlight ? "Elapsed" : "Duration"}>
<span className="font-mono">
{durationMs != null ? formatDurationMs(durationMs) : "--"}
</span>
</PopoverRow>
</PopoverRows>
</>
);
}
function phaseRow(phase: RunPhase, nowMs: number): Row {
const endMs = phase.endMs;
const inFlight = endMs == null;
const closedEnd = endMs ?? nowMs;
const rawDuration = closedEnd - phase.startMs;
const durationMs = rawDuration >= 0 ? rawDuration : null;
return {
key: `phase:${phase.kind}`,
kind: "phase",
label: phase.label,
startMs: phase.startMs,
endMs,
durationMs,
barClass: inFlight ? "bg-fg-3/40 animate-pulse" : "bg-fg-3/40",
href: null,
popover: phasePopover(phase, durationMs, inFlight),
};
}
function stagePopover(
stage: RunStage,
durationMs: number | null,
inFlight: boolean,
): ReactNode {
return (
<>
<PopoverHeader>{formatStageLabel(stage)}</PopoverHeader>
<PopoverRows>
<PopoverRow label="Status">
<span
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium ${stageStatusTone(stage.status)}`}
>
{stageStatusLabel(stage.status)}
</span>
</PopoverRow>
{stage.started_at && (
<PopoverRow label="Started">{formatAbsoluteTs(stage.started_at)}</PopoverRow>
)}
<PopoverRow label={inFlight ? "Elapsed" : "Duration"}>
<span className="font-mono">
{durationMs != null ? formatDurationMs(durationMs) : "--"}
</span>
</PopoverRow>
</PopoverRows>
</>
);
}
function stageRow(runId: string, stage: RunStage, nowMs: number): Row | null {
if (!stage.started_at) return null;
const startMs = Date.parse(stage.started_at);
if (Number.isNaN(startMs)) return null;
const inFlight = isStageInFlight(stage.status);
const wallMs = stage.wall_time_ms ?? null;
const endMs = inFlight ? null : wallMs != null ? startMs + wallMs : null;
const durationMs = inFlight ? nowMs - startMs : wallMs;
return {
key: `stage:${stage.id}`,
kind: "stage",
label: formatStageLabel(stage),
startMs,
endMs,
durationMs,
barClass: stageBarClass(stage.status),
href: `/runs/${runId}/stages/${encodeURIComponent(stage.id)}`,
popover: stagePopover(stage, durationMs, inFlight),
};
}
function buildRows({
runId,
events,
stages,
createdAtIso,
nowMs,
}: {
runId: string;
events: EventEnvelope[];
stages: RunStage[];
createdAtIso: string;
nowMs: number;
}): Row[] {
const phases = deriveRunPhases(events, createdAtIso).map((p) => phaseRow(p, nowMs));
const stageRows: Row[] = [];
for (const stage of stages) {
if (!isVisibleStage(stage.node_id)) continue;
const row = stageRow(runId, stage, nowMs);
if (row) stageRows.push(row);
}
stageRows.sort((a, b) => a.startMs - b.startMs);
return [...phases, ...stageRows];
}
export function RunWaterfall({
runId,
events,
stages,
createdAtIso,
completedAtIso,
}: WaterfallProps) {
const nowMs = useTickingNow(true, 1000);
const rows = useMemo(
() => buildRows({ runId, events, stages, createdAtIso, nowMs }),
[runId, events, stages, createdAtIso, nowMs],
);
const createdMs = Date.parse(createdAtIso);
const completedMs = completedAtIso ? Date.parse(completedAtIso) : null;
const hasInFlight = rows.some((r) => r.endMs == null);
const lastRowEnd = rows.reduce(
(max, r) => Math.max(max, r.endMs ?? r.startMs + (r.durationMs ?? 0)),
createdMs,
);
const timelineStartMs = createdMs;
const timelineEndMs = Math.max(
timelineStartMs + 1_000,
completedMs ?? (hasInFlight ? nowMs : lastRowEnd),
);
const rangeMs = timelineEndMs - timelineStartMs;
const ticks = useMemo(() => {
const interval = chooseTickIntervalMs(rangeMs);
const out: { offsetMs: number; pct: number; label: string }[] = [];
for (let t = 0; t <= rangeMs + 1; t += interval) {
out.push({
offsetMs: t,
pct: (t / rangeMs) * 100,
label: t === 0 ? "0s" : formatDurationMs(t),
});
}
return out;
}, [rangeMs]);
if (rows.length === 0) {
return (
<div className="px-4 py-12 text-sm text-fg-muted">
Waterfall will populate as the run progresses.
</div>
);
}
return (
<div className="min-w-0 flex-1 overflow-y-auto pt-2 pb-[calc(1.5rem+var(--fabro-interview-dock-clearance,0px))]">
<div className="sticky top-0 z-10 bg-page">
<div className="flex items-end gap-3 px-3 pb-1">
<div className="w-48 shrink-0" />
<div className="relative h-5 flex-1">
{ticks.map((tick) => (
<div
key={tick.offsetMs}
className="absolute top-0 h-full"
style={{ left: `${tick.pct}%` }}
>
<div className="absolute top-0 h-2 w-px bg-line-strong" />
<span className="absolute top-2 -translate-x-1/2 whitespace-nowrap font-mono text-[10px] text-fg-muted">
{tick.label}
</span>
</div>
))}
</div>
<div className="w-16 shrink-0" />
</div>
<div className="border-b border-line" />
</div>
<div>
{rows.map((row) => {
const startPct =
((row.startMs - timelineStartMs) / rangeMs) * 100;
const closedEnd = row.endMs ?? timelineEndMs;
const rawWidthPct = ((closedEnd - row.startMs) / rangeMs) * 100;
const widthPct = Math.max(MIN_BAR_WIDTH_PCT, rawWidthPct);
const durationLabel =
row.durationMs != null ? formatDurationMs(row.durationMs) : "";
return (
<WaterfallRow
key={row.key}
row={row}
startPct={startPct}
widthPct={widthPct}
durationLabel={durationLabel}
/>
);
})}
</div>
</div>
);
}
function WaterfallRow({
row,
startPct,
widthPct,
durationLabel,
}: {
row: Row;
startPct: number;
widthPct: number;
durationLabel: string;
}) {
const labelClass =
row.kind === "phase"
? "text-fg-muted"
: "text-fg-2";
const inner = (
<div className="flex items-center gap-3 px-3 py-1.5 hover:bg-overlay">
<div className={`w-48 shrink-0 truncate font-mono text-xs ${labelClass}`}>
{row.label}
</div>
<div className="relative h-3 flex-1">
<div
className={`absolute top-0 h-full rounded-sm ${row.barClass}`}
style={{ left: `${startPct}%`, width: `${widthPct}%` }}
/>
</div>
<div className="w-16 shrink-0 text-right font-mono text-[11px] tabular-nums text-fg-muted">
{durationLabel}
</div>
</div>
);
const trigger = row.href ? (
<Link
to={row.href}
className="block focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500"
>
{inner}
</Link>
) : (
inner
);
return (
<HoverCard content={row.popover} className="block">
{trigger}
</HoverCard>
);
}