mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-11 22:53:00 +00:00
## 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>
324 lines
9.3 KiB
TypeScript
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>
|
|
);
|
|
}
|