From b196a97ac48c8e9e4e4db89a972d3323096caa41 Mon Sep 17 00:00:00 2001 From: "fabro-sh-0530[bot]" <281434857+fabro-sh-0530[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 10:37:29 -0400 Subject: [PATCH] Introduce approved effect hooks and migrate direct useEffect calls (#425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 `` 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
Ran 3 stages in 114m 5s for $95.71 | 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** |
Ran Goal.fabro (4 nodes and 5 edges) ```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"] } ```
⚒️ Generated with [Fabro](https://fabro.sh) --------- Co-authored-by: Fabro Co-authored-by: Bryan Helmkamp --- .../components/chats/ask-fabro-sidebar.tsx | 8 +- apps/fabro-web/app/components/event-debug.tsx | 19 +- .../app/components/floating-tooltip.tsx | 52 +--- .../app/components/run-waterfall.tsx | 14 +- .../app/components/runs-list/preferences.ts | 4 +- .../runs-list/selection-checkbox.tsx | 10 +- .../app/components/terminal-view.tsx | 195 +------------ apps/fabro-web/app/components/toast.test.tsx | 6 +- apps/fabro-web/app/hooks/effects.ts | 206 ++++++++++++++ .../app/hooks/use-annotated-run-graph-svg.ts | 183 ++++++++++++ .../app/hooks/use-changed-files-tree-sync.ts | 72 +++++ .../app/hooks/use-data-updated-at.ts | 10 + .../app/hooks/use-dot-language-ready.ts | 31 +++ .../app/hooks/use-file-tree-model.ts | 16 ++ .../use-floating-tooltip-measurements.ts | 55 ++++ .../app/hooks/use-focus-after-refresh.ts | 20 ++ .../app/hooks/use-insights-query-runner.ts | 116 ++++++++ .../app/hooks/use-install-effects.ts | 128 +++++++++ .../app/hooks/use-minimum-refresh-spinner.ts | 30 ++ .../hooks/use-pending-chat-autoresponse.ts | 30 ++ .../app/hooks/use-rendered-viz-diagram.ts | 54 ++++ .../app/hooks/use-run-file-deep-link.ts | 47 ++++ apps/fabro-web/app/hooks/use-run-toasts.ts | 4 + .../app/hooks/use-terminal-session.ts | 207 ++++++++++++++ apps/fabro-web/app/install-app.test.tsx | 13 +- apps/fabro-web/app/install-app.tsx | 260 ++++++++---------- apps/fabro-web/app/install-query.ts | 31 +++ apps/fabro-web/app/layouts/app-shell.tsx | 12 +- apps/fabro-web/app/lib/api-client.test.ts | 6 +- apps/fabro-web/app/lib/api-client.ts | 16 +- apps/fabro-web/app/lib/ask-fabro-layout.tsx | 51 ---- apps/fabro-web/app/lib/board-events.ts | 4 + apps/fabro-web/app/lib/live-events.ts | 16 ++ apps/fabro-web/app/lib/mutations.test.ts | 18 +- apps/fabro-web/app/lib/run-events.ts | 4 + apps/fabro-web/app/lib/time.ts | 10 +- apps/fabro-web/app/routes/ask-fabro.tsx | 1 + .../app/routes/automation-definition.tsx | 15 +- .../app/routes/automation-diagram.tsx | 39 +-- apps/fabro-web/app/routes/chats-detail.tsx | 26 +- apps/fabro-web/app/routes/insights-editor.tsx | 141 +--------- apps/fabro-web/app/routes/redirect-home.tsx | 19 +- apps/fabro-web/app/routes/run-artifacts.tsx | 26 +- apps/fabro-web/app/routes/run-children.tsx | 207 +++++++------- apps/fabro-web/app/routes/run-detail.tsx | 57 ++-- .../app/routes/run-detail/docked-controls.tsx | 18 +- .../app/routes/run-detail/lifecycle-toasts.ts | 110 ++------ apps/fabro-web/app/routes/run-detail/model.ts | 8 +- .../app/routes/run-files.render.test.tsx | 31 +++ apps/fabro-web/app/routes/run-files.tsx | 176 +++++------- .../routes/run-files/file-tree-sidebar.tsx | 46 +--- .../app/routes/run-files/keyboard.ts | 19 +- apps/fabro-web/app/routes/run-overview.tsx | 178 ++---------- .../routes/run-sandbox/filesystem-panel.tsx | 6 +- apps/fabro-web/app/routes/run-source.tsx | 16 +- apps/fabro-web/app/routes/run-terminal.tsx | 10 +- .../app/routes/runs.preferences.test.tsx | 95 ++++++- apps/fabro-web/app/routes/runs.tsx | 168 +++++------ .../app/routes/runs/workspace-preferences.ts | 13 +- .../app/routes/settings-live-events.test.tsx | 4 + .../app/routes/settings-live-events.tsx | 12 +- apps/fabro-web/app/routes/settings-models.tsx | 12 +- apps/fabro-web/app/routes/start.tsx | 7 +- 63 files changed, 2023 insertions(+), 1395 deletions(-) create mode 100644 apps/fabro-web/app/hooks/effects.ts create mode 100644 apps/fabro-web/app/hooks/use-annotated-run-graph-svg.ts create mode 100644 apps/fabro-web/app/hooks/use-changed-files-tree-sync.ts create mode 100644 apps/fabro-web/app/hooks/use-data-updated-at.ts create mode 100644 apps/fabro-web/app/hooks/use-dot-language-ready.ts create mode 100644 apps/fabro-web/app/hooks/use-file-tree-model.ts create mode 100644 apps/fabro-web/app/hooks/use-floating-tooltip-measurements.ts create mode 100644 apps/fabro-web/app/hooks/use-focus-after-refresh.ts create mode 100644 apps/fabro-web/app/hooks/use-insights-query-runner.ts create mode 100644 apps/fabro-web/app/hooks/use-install-effects.ts create mode 100644 apps/fabro-web/app/hooks/use-minimum-refresh-spinner.ts create mode 100644 apps/fabro-web/app/hooks/use-pending-chat-autoresponse.ts create mode 100644 apps/fabro-web/app/hooks/use-rendered-viz-diagram.ts create mode 100644 apps/fabro-web/app/hooks/use-run-file-deep-link.ts create mode 100644 apps/fabro-web/app/hooks/use-terminal-session.ts create mode 100644 apps/fabro-web/app/install-query.ts delete mode 100644 apps/fabro-web/app/lib/ask-fabro-layout.tsx diff --git a/apps/fabro-web/app/components/chats/ask-fabro-sidebar.tsx b/apps/fabro-web/app/components/chats/ask-fabro-sidebar.tsx index 22804aec1..a300e1d6e 100644 --- a/apps/fabro-web/app/components/chats/ask-fabro-sidebar.tsx +++ b/apps/fabro-web/app/components/chats/ask-fabro-sidebar.tsx @@ -8,7 +8,6 @@ import { XMarkIcon } from "@heroicons/react/24/outline"; import remarkGfm from "remark-gfm"; import { createAskFabroAdapter } from "../../lib/ask-fabro-runtime"; -import { useAskFabroLayout } from "../../lib/ask-fabro-layout"; import SidebarComposer from "./sidebar-composer"; import SidebarWelcome from "./sidebar-welcome"; import ToolCallSummary from "./tool-call-summary"; @@ -44,6 +43,7 @@ export default function AskFabroSidebar({ defaultModel, width, onWidthChange, + onResizeActiveChange, }: { isOpen: boolean; onClose: () => void; @@ -51,6 +51,7 @@ export default function AskFabroSidebar({ defaultModel?: string | null; width: number; onWidthChange: (width: number) => void; + onResizeActiveChange: (active: boolean) => void; }) { const adapter = useMemo( () => createAskFabroAdapter({ runId, defaultModel }), @@ -58,7 +59,6 @@ export default function AskFabroSidebar({ ); const runtime = useLocalRuntime(adapter); - const { setIsResizing } = useAskFabroLayout(); const [isDragging, setIsDragging] = useState(false); // Pointer X and width captured at drag start, so each move resolves to an // absolute width rather than accumulating rounding error. @@ -69,7 +69,7 @@ export default function AskFabroSidebar({ event.currentTarget.setPointerCapture(event.pointerId); dragOrigin.current = { x: event.clientX, width }; setIsDragging(true); - setIsResizing(true); + onResizeActiveChange(true); }; const handlePointerMove = (event: React.PointerEvent) => { @@ -88,7 +88,7 @@ export default function AskFabroSidebar({ event.currentTarget.releasePointerCapture(event.pointerId); dragOrigin.current = null; setIsDragging(false); - setIsResizing(false); + onResizeActiveChange(false); }; return ( diff --git a/apps/fabro-web/app/components/event-debug.tsx b/apps/fabro-web/app/components/event-debug.tsx index 2d9ccb8c6..30f77e04f 100644 --- a/apps/fabro-web/app/components/event-debug.tsx +++ b/apps/fabro-web/app/components/event-debug.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Listbox, ListboxButton, @@ -26,6 +26,7 @@ import { type DebugCategory, } from "./event-debug-helpers"; import { FloatingTooltip } from "./floating-tooltip"; +import { useWindowEvent } from "../hooks/effects"; export function DebugEventRow({ event, @@ -77,16 +78,14 @@ export function DetailsPanel({ onClose: () => void; children: React.ReactNode; }) { - // react-doctor-disable-next-line react-doctor/prefer-use-effect-event -- React's useEffectEvent is not in the installed React type surface yet. - useEffect(() => { - if (!isOpen) return; - function handleKey(event: KeyboardEvent) { + useWindowEvent( + "keydown", + (event) => { if (event.key === "Escape") onClose(); - } - window.addEventListener("keydown", handleKey); - return () => window.removeEventListener("keydown", handleKey); - // react-doctor-disable-next-line react-doctor/prefer-use-effect-event -- React's useEffectEvent is not in the installed React type surface yet. - }, [isOpen, onClose]); + }, + undefined, + isOpen, + ); return (
(null); - const [size, setSize] = useState({ height: 0, width: 0 }); - const [viewport, setViewport] = useState(() => - typeof window === "undefined" ? { height: 0, width: 0 } : viewportSize(), - ); - - useLayoutEffect(() => { - const node = ref.current; - if (!node) return; - - const updateSize = () => { - const next = node.getBoundingClientRect(); - setSize((prev) => - prev.height === next.height && prev.width === next.width - ? prev - : { height: next.height, width: next.width }, - ); - }; - const updateViewport = () => { - const next = viewportSize(); - setViewport((prev) => - prev.height === next.height && prev.width === next.width ? prev : next, - ); - }; - - updateSize(); - updateViewport(); - const resizeObserver = - typeof ResizeObserver === "undefined" - ? null - : new ResizeObserver(updateSize); - resizeObserver?.observe(node); - window.addEventListener("resize", updateViewport); - return () => { - resizeObserver?.disconnect(); - window.removeEventListener("resize", updateViewport); - }; - }, []); + const { ref, size, viewport } = useFloatingTooltipMeasurements(); if (typeof document === "undefined") return null; diff --git a/apps/fabro-web/app/components/run-waterfall.tsx b/apps/fabro-web/app/components/run-waterfall.tsx index 33a7a2c8e..907ccbb75 100644 --- a/apps/fabro-web/app/components/run-waterfall.tsx +++ b/apps/fabro-web/app/components/run-waterfall.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useMemo, type ReactNode } from "react"; import { Link } from "react-router"; import { StageState, type RunStage } from "@qltysh/fabro-api-client"; @@ -11,6 +11,7 @@ import { 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 { @@ -35,15 +36,6 @@ interface Row { const MIN_BAR_WIDTH_PCT = 0.4; -function useTickingNow(intervalMs: number): number { - const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - const id = setInterval(() => setNow(Date.now()), intervalMs); - return () => clearInterval(id); - }, [intervalMs]); - return now; -} - function stageBarClass(status: StageState): string { switch (status) { case StageState.RUNNING: @@ -194,7 +186,7 @@ export function RunWaterfall({ createdAtIso, completedAtIso, }: WaterfallProps) { - const nowMs = useTickingNow(1000); + const nowMs = useTickingNow(true, 1000); const rows = useMemo( () => buildRows({ runId, events, stages, createdAtIso, nowMs }), [runId, events, stages, createdAtIso, nowMs], diff --git a/apps/fabro-web/app/components/runs-list/preferences.ts b/apps/fabro-web/app/components/runs-list/preferences.ts index 4e4e594f9..c48becdf5 100644 --- a/apps/fabro-web/app/components/runs-list/preferences.ts +++ b/apps/fabro-web/app/components/runs-list/preferences.ts @@ -328,7 +328,7 @@ export function loadStoredRunsWorkspaceSearchParams( // `/runs`), fall back to stored preferences so the first render already // reflects the user's view/archived/etc. choice instead of route defaults. // Without this, users whose only runs are archived briefly see the empty -// Quick Start landing before a post-commit effect restores `archived=1`. +// Quick Start landing before a post-commit URL repair restores `archived=1`. export function resolveRunsWorkspaceSearchParams( urlSearchParams: URLSearchParams, ): URLSearchParams { @@ -355,7 +355,7 @@ export function persistRunsWorkspacePreferences( } const CHILD_RUNS_LIST_PREFERENCES_VERSION = 1; -const CHILD_RUNS_LIST_PREFERENCES_STORAGE_KEY = "fabro:run-children-preferences:v1"; +export const CHILD_RUNS_LIST_PREFERENCES_STORAGE_KEY = "fabro:run-children-preferences:v1"; const CHILD_RUNS_LIST_PARAM_KEYS = [ "search", "created", diff --git a/apps/fabro-web/app/components/runs-list/selection-checkbox.tsx b/apps/fabro-web/app/components/runs-list/selection-checkbox.tsx index 7b7b1649d..321cb6497 100644 --- a/apps/fabro-web/app/components/runs-list/selection-checkbox.tsx +++ b/apps/fabro-web/app/components/runs-list/selection-checkbox.tsx @@ -1,5 +1,3 @@ -import { useEffect, useRef } from "react"; - export function SelectionCheckbox({ checked, indeterminate = false, @@ -13,13 +11,11 @@ export function SelectionCheckbox({ onChange: () => void; ariaLabel: string; }) { - const ref = useRef(null); - useEffect(() => { - if (ref.current) ref.current.indeterminate = indeterminate; - }, [indeterminate]); return ( { + if (input) input.indeterminate = indeterminate; + }} type="checkbox" aria-label={ariaLabel} checked={checked} diff --git a/apps/fabro-web/app/components/terminal-view.tsx b/apps/fabro-web/app/components/terminal-view.tsx index eed8b4d8c..89fb91352 100644 --- a/apps/fabro-web/app/components/terminal-view.tsx +++ b/apps/fabro-web/app/components/terminal-view.tsx @@ -1,12 +1,9 @@ import { useCallback, - useEffect, useReducer, useRef, useState, } from "react"; -import type { Terminal as XtermTerminal } from "@xterm/xterm"; -import type { FitAddon as XtermFitAddon } from "@xterm/addon-fit"; import { ArrowPathIcon, ArrowTopRightOnSquareIcon, @@ -20,52 +17,19 @@ import { apiData, humanInTheLoopApi } from "../lib/api-client"; import { useRunState } from "../lib/queries"; import { buildFullScreenTerminalUrl, - buildTerminalWebSocketUrl, - parseTerminalServerMessage, sandboxStatusDetail, terminalAccessCommandLabel, } from "./terminal-view-helpers"; +import { + TERMINAL_BACKGROUND, + useTerminalSession, + type ConnectionStatus, + type TerminalConnectionError, +} from "../hooks/use-terminal-session"; const ICON_BUTTON_CLASS = "inline-flex size-9 items-center justify-center rounded-lg text-fg-2 outline-1 -outline-offset-1 outline-white/10 transition-colors hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500"; -type ConnectionStatus = "connecting" | "ready" | "closed" | "error"; - -const TERMINAL_BACKGROUND = "#05080F"; - -// Pin the cell to a whole-pixel height so xterm's fit math stays exact. -// fontSize × lineHeight = 13 × (19/13) = 19px → no sub-pixel rounding, -// no bottom-row clipping. -const TERMINAL_FONT_SIZE = 13; -const TERMINAL_CELL_HEIGHT_PX = 19; -const TERMINAL_LINE_HEIGHT = TERMINAL_CELL_HEIGHT_PX / TERMINAL_FONT_SIZE; - -const TERMINAL_THEME = { - background: TERMINAL_BACKGROUND, - foreground: "#E6EDF3", - cursor: "#7AC4E5", - cursorAccent: "#05080F", - selectionBackground: "#1F4F73", - - black: "#05080F", - red: "#FF6B6B", - green: "#5EE6A8", - yellow: "#FFC857", - blue: "#82AAFF", - magenta: "#C792EA", - cyan: "#7AC4E5", - white: "#D5DCE3", - - brightBlack: "#4B5563", - brightRed: "#FF8B8B", - brightGreen: "#85F5C2", - brightYellow: "#FFD98A", - brightBlue: "#A4C4FF", - brightMagenta: "#E0B6FF", - brightCyan: "#A8DFF5", - brightWhite: "#FFFFFF", -}; - function terminalAccessCommandCopiedMessage(provider: string | null): string { return provider === "docker" ? "Docker exec command copied." : "SSH command copied."; } @@ -76,15 +40,6 @@ function terminalAccessCommandErrorMessage(provider: string | null): string { : "Could not copy SSH command."; } -function sendResize(socket: WebSocket | null, terminal: XtermTerminal | null) { - if (!socket || socket.readyState !== WebSocket.OPEN || !terminal) return; - socket.send(JSON.stringify({ - type: "resize", - cols: terminal.cols, - rows: terminal.rows, - })); -} - function statusDotClasses(status: ConnectionStatus): string { switch (status) { case "ready": @@ -157,12 +112,16 @@ export default function TerminalView({ const accessCommandLabel = terminalAccessCommandLabel(provider); const [connectionKey, reconnectTerminal] = useReducer((key: number) => key + 1, 0); const [status, setStatus] = useState("connecting"); - const [error, setError] = useState<{ message: string; recoverable: boolean } | null>(null); + const [error, setError] = useState(null); const terminalEl = useRef(null); - const terminalRef = useRef(null); - const fitRef = useRef(null); - const socketRef = useRef(null); const headingId = `run-terminal-${runId}`; + useTerminalSession({ + connectionKey, + runId, + setError, + setStatus, + terminalEl, + }); const reconnect = useCallback(() => { setError(null); @@ -188,132 +147,6 @@ export default function TerminalView({ } }, [accessCommandLabel, runId, provider, push]); - // react-doctor-disable-next-line react-doctor/effect-needs-cleanup -- listeners, socket, xterm, and ResizeObserver are disposed in the returned cleanup. - useEffect(() => { - if (!terminalEl.current) return undefined; - - let disposed = false; - let resizeObserver: ResizeObserver | null = null; - const textEncoder = new TextEncoder(); - const disposables: Array<{ dispose: () => void }> = []; - - async function connect() { - setStatus("connecting"); - setError(null); - - const [{ Terminal }, { FitAddon }] = await Promise.all([ - import("@xterm/xterm"), - import("@xterm/addon-fit"), - ]); - if (disposed || !terminalEl.current) return; - - const terminal = new Terminal({ - cursorBlink: true, - convertEol: true, - fontFamily: "\"JetBrains Mono\", ui-monospace, monospace", - fontSize: TERMINAL_FONT_SIZE, - lineHeight: TERMINAL_LINE_HEIGHT, - scrollback: 5000, - theme: TERMINAL_THEME, - }); - const fitAddon = new FitAddon(); - terminal.loadAddon(fitAddon); - terminal.open(terminalEl.current); - fitAddon.fit(); - terminal.focus(); - terminalRef.current = terminal; - fitRef.current = fitAddon; - - const socket = new WebSocket(buildTerminalWebSocketUrl(window.location, runId)); - socket.binaryType = "arraybuffer"; - socketRef.current = socket; - - disposables.push(terminal.onData((data) => { - if (socket.readyState === WebSocket.OPEN) { - socket.send(textEncoder.encode(data)); - } - })); - - const handleOpen = () => { - sendResize(socket, terminal); - }; - const handleMessage = (event: MessageEvent) => { - if (typeof event.data === "string") { - const message = parseTerminalServerMessage(event.data); - if (!message) return; - if (message.type === "ready") { - setStatus("ready"); - return; - } - if (message.type === "closed") { - setStatus("closed"); - return; - } - setStatus("error"); - setError({ - message: message.message ?? "Terminal session failed.", - recoverable: false, - }); - return; - } - const bytes = event.data instanceof ArrayBuffer - ? new Uint8Array(event.data) - : event.data; - terminal.write(bytes); - }; - const handleClose = () => { - setStatus((current) => current === "error" ? current : "closed"); - }; - const handleError = () => { - setStatus("error"); - setError({ - message: "Terminal WebSocket connection failed.", - recoverable: true, - }); - }; - socket.addEventListener("open", handleOpen); - socket.addEventListener("message", handleMessage); - socket.addEventListener("close", handleClose); - socket.addEventListener("error", handleError); - disposables.push({ - dispose: () => { - socket.removeEventListener("open", handleOpen); - socket.removeEventListener("message", handleMessage); - socket.removeEventListener("close", handleClose); - socket.removeEventListener("error", handleError); - }, - }); - - resizeObserver = new ResizeObserver(() => { - fitAddon.fit(); - sendResize(socket, terminal); - }); - resizeObserver.observe(terminalEl.current); - - if (typeof document !== "undefined" && document.fonts?.ready) { - void document.fonts.ready.then(() => { - if (disposed) return; - fitAddon.fit(); - sendResize(socket, terminal); - }); - } - } - - void connect(); - - return () => { - disposed = true; - resizeObserver?.disconnect(); - for (const disposable of disposables) disposable.dispose(); - socketRef.current?.send(JSON.stringify({ type: "close" })); - socketRef.current?.close(); - socketRef.current = null; - terminalRef.current?.dispose(); - terminalRef.current = null; - fitRef.current = null; - }; - }, [connectionKey, runId]); - return (
) => void; }) { const api = useToast(); - - useEffect(() => { - onReady?.(api); - }, [api, onReady]); + onReady?.(api); return null; } diff --git a/apps/fabro-web/app/hooks/effects.ts b/apps/fabro-web/app/hooks/effects.ts new file mode 100644 index 000000000..fe552e790 --- /dev/null +++ b/apps/fabro-web/app/hooks/effects.ts @@ -0,0 +1,206 @@ +import { + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, + type EffectCallback, + type RefObject, +} from "react"; + +/** + * Synchronizes React with a resource that is created for the mounted lifetime + * only. The returned cleanup is run on unmount, including Strict Mode remounts. + */ +export function useMountEffect(setup: EffectCallback): void { + useEffect(setup, []); +} + +/** + * Synchronizes React with the browser timer queue. The interval is started + * while `active` is true and is always cleared before the hook resubscribes or + * unmounts. + */ +export function useInterval( + callback: () => void, + delayMs: number, + active = true, +): void { + const callbackRef = useRef(callback); + callbackRef.current = callback; + + useEffect(() => { + if (!active) return undefined; + const id = setInterval(() => callbackRef.current(), delayMs); + return () => clearInterval(id); + }, [active, delayMs]); +} + +/** + * Synchronizes React with the browser timer queue. The timeout is scheduled + * while `active` is true and is always cleared before it can fire after + * unmount. + */ +export function useTimeout( + callback: () => void, + delayMs: number, + active = true, +): void { + const callbackRef = useRef(callback); + callbackRef.current = callback; + + useEffect(() => { + if (!active) return undefined; + const id = setTimeout(() => callbackRef.current(), delayMs); + return () => clearTimeout(id); + }, [active, delayMs]); +} + +/** + * Synchronizes a value with the browser timer queue. Pending debounce timers are + * cleared when the value or delay changes and on unmount. + */ +export function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const id = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(id); + }, [value, delayMs]); + + return debounced; +} + +/** + * Synchronizes React with a browser `window` event listener. The listener is + * removed before resubscribe and on unmount; the handler sees the latest render. + */ +export function useWindowEvent( + type: K, + handler: (event: WindowEventMap[K]) => void, + options?: AddEventListenerOptions | boolean, + active = true, +): void { + const handlerRef = useRef(handler); + handlerRef.current = handler; + + useEffect(() => { + if (!active || typeof window === "undefined") return undefined; + const listener = (event: WindowEventMap[K]) => handlerRef.current(event); + window.addEventListener(type, listener as EventListener, options); + return () => { + window.removeEventListener(type, listener as EventListener, options); + }; + }, [active, options, type]); +} + +/** + * Synchronizes React with a browser `document` event listener. The listener is + * removed before resubscribe and on unmount; the handler sees the latest render. + */ +export function useDocumentEvent( + type: K, + handler: (event: DocumentEventMap[K]) => void, + options?: AddEventListenerOptions | boolean, + active = true, +): void { + const handlerRef = useRef(handler); + handlerRef.current = handler; + + useEffect(() => { + if (!active || typeof document === "undefined") return undefined; + const listener = (event: DocumentEventMap[K]) => handlerRef.current(event); + document.addEventListener(type, listener as EventListener, options); + return () => { + document.removeEventListener(type, listener as EventListener, options); + }; + }, [active, options, type]); +} + +/** + * Synchronizes React with `document.title`. The previous title is restored when + * the title changes or the component unmounts. + */ +export function useDocumentTitle(title: string): void { + useEffect(() => { + if (typeof document === "undefined") return undefined; + const previous = document.title; + document.title = title; + return () => { + document.title = previous; + }; + }, [title]); +} + +/** + * Synchronizes React rendering with a browser media query using + * `useSyncExternalStore`. The media query listener is removed on unsubscribe. + */ +export function useMediaQuery(query: string, serverSnapshot = false): boolean { + const subscribe = useCallback( + (onStoreChange: () => void) => { + if (typeof window === "undefined") return () => undefined; + const mediaQuery = window.matchMedia(query); + mediaQuery.addEventListener("change", onStoreChange); + return () => mediaQuery.removeEventListener("change", onStoreChange); + }, + [query], + ); + const getSnapshot = useCallback( + () => typeof window !== "undefined" && window.matchMedia(query).matches, + [query], + ); + const getServerSnapshot = useCallback( + () => serverSnapshot, + [serverSnapshot], + ); + + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} + +/** + * Synchronizes React rendering with `window.location.hash` using + * `useSyncExternalStore`. The `hashchange` listener is removed on unsubscribe. + */ +export function useLocationHash(serverSnapshot = ""): string { + const subscribe = useCallback((onStoreChange: () => void) => { + if (typeof window === "undefined") return () => undefined; + window.addEventListener("hashchange", onStoreChange); + return () => window.removeEventListener("hashchange", onStoreChange); + }, []); + const getSnapshot = useCallback( + () => typeof window === "undefined" ? serverSnapshot : window.location.hash, + [serverSnapshot], + ); + const getServerSnapshot = useCallback( + () => serverSnapshot, + [serverSnapshot], + ); + + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} + +/** + * Synchronizes React with a browser `ResizeObserver`. The observer is + * disconnected before resubscribe and on unmount; the callback sees the latest + * render. + */ +export function useResizeObserver( + ref: RefObject, + callback: ResizeObserverCallback, + active = true, +): void { + const callbackRef = useRef(callback); + callbackRef.current = callback; + + useEffect(() => { + if (!active || typeof ResizeObserver === "undefined") return undefined; + const node = ref.current; + if (!node) return undefined; + const observer = new ResizeObserver((entries, resizeObserver) => { + callbackRef.current(entries, resizeObserver); + }); + observer.observe(node); + return () => observer.disconnect(); + }, [active, ref]); +} diff --git a/apps/fabro-web/app/hooks/use-annotated-run-graph-svg.ts b/apps/fabro-web/app/hooks/use-annotated-run-graph-svg.ts new file mode 100644 index 000000000..70c4d5314 --- /dev/null +++ b/apps/fabro-web/app/hooks/use-annotated-run-graph-svg.ts @@ -0,0 +1,183 @@ +import { useEffect } from "react"; + +import { graphTheme } from "../lib/graph-theme"; +import { + ACTIVE_STAGE_STATES, + SUCCEEDED_STAGE_STATES, + aggregateGraphNodeStatus, + type Stage, +} from "../lib/stage-sidebar"; + +const HOVER_OPEN_DELAY_MS = 200; + +export interface RunGraphNodeHover { + stage: Stage; + rect: DOMRect; +} + +/** + * Synchronizes Graphviz SVG markup with imperative DOM annotations, animation + * nodes, and pointer listeners. Timers and DOM listeners are cleaned up before + * resubscribe and on unmount. + */ +export function useAnnotatedRunGraphSvg({ + graphSvg, + innerRef, + onHoverChange, + onStageClick, + stages, + svgRef, + terminalOutcome, +}: { + graphSvg: string | null | undefined; + innerRef: { current: HTMLDivElement | null }; + onHoverChange: (hover: RunGraphNodeHover | null) => void; + onStageClick: (stageId: string) => void; + stages: Stage[]; + svgRef: { current: SVGSVGElement | null }; + terminalOutcome: "succeeded" | "failed" | "dead" | null; +}) { + useEffect(() => { + const inner = innerRef.current; + if (!inner || !graphSvg) return; + + inner.innerHTML = graphSvg; + const svg = inner.querySelector("svg"); + if (!svg) return; + svgRef.current = svg; + + const stageById = new Map(); + for (const stage of stages) stageById.set(stage.id, stage); + + const gt = graphTheme; + const aggregated = aggregateGraphNodeStatus(stages); + const runningDotIds = new Set(); + const failedDotIds = new Set(); + const completedDotIds = new Set(); + const dotIdToStageId = new Map(); + for (const [nodeId, { displayStatus, latestStageId }] of aggregated) { + dotIdToStageId.set(nodeId, latestStageId); + if (ACTIVE_STAGE_STATES.has(displayStatus)) { + runningDotIds.add(nodeId); + } else if (displayStatus === "failed") { + failedDotIds.add(nodeId); + } else if (SUCCEEDED_STAGE_STATES.has(displayStatus)) { + completedDotIds.add(nodeId); + } + } + + const ns = "http://www.w3.org/2000/svg"; + let openTimer: ReturnType | null = null; + const clearOpenTimer = () => { + if (openTimer !== null) { + clearTimeout(openTimer); + openTimer = null; + } + }; + const listeners: Array<{ target: Element; type: string; listener: EventListener }> = []; + const addListener = (target: Element, type: string, listener: EventListener) => { + target.addEventListener(type, listener); + listeners.push({ target, type, listener }); + }; + + for (const group of svg.querySelectorAll(".node")) { + const nodeId = group.querySelector("title")?.textContent?.trim(); + if (!nodeId) continue; + + const stageId = dotIdToStageId.get(nodeId); + const stage = stageId ? stageById.get(stageId) : undefined; + if (stageId) { + (group as SVGElement).style.cursor = "pointer"; + addListener(group, "click", () => onStageClick(stageId)); + } + if (stage) { + addListener(group, "mouseenter", () => { + clearOpenTimer(); + const target = group as SVGGElement; + openTimer = setTimeout(() => { + openTimer = null; + onHoverChange({ stage, rect: target.getBoundingClientRect() }); + }, HOVER_OPEN_DELAY_MS); + }); + addListener(group, "mouseleave", () => { + clearOpenTimer(); + onHoverChange(null); + }); + } + + if (nodeId === "exit" && terminalOutcome) { + const isSuccess = terminalOutcome === "succeeded"; + const fill = isSuccess ? gt.completedFill : gt.failedFill; + const border = isSuccess ? gt.completedBorder : gt.failedBorder; + const text = isSuccess ? gt.completedText : gt.failedText; + for (const shape of group.querySelectorAll("ellipse, polygon, path")) { + shape.setAttribute("fill", fill); + shape.setAttribute("stroke", border); + } + for (const t of group.querySelectorAll("text")) { + t.setAttribute("fill", text); + } + } else if (runningDotIds.has(nodeId)) { + for (const shape of group.querySelectorAll("ellipse, polygon, path")) { + shape.setAttribute("fill", gt.runningFill); + shape.setAttribute("stroke", gt.runningBorder); + shape.setAttribute("stroke-width", "2"); + + const animFill = document.createElementNS(ns, "animate"); + animFill.setAttribute("attributeName", "fill"); + animFill.setAttribute( + "values", + `${gt.runningFill};${gt.runningPulseFill};${gt.runningFill}`, + ); + animFill.setAttribute("dur", "1.5s"); + animFill.setAttribute("repeatCount", "indefinite"); + shape.appendChild(animFill); + + const animStroke = document.createElementNS(ns, "animate"); + animStroke.setAttribute("attributeName", "stroke"); + animStroke.setAttribute( + "values", + `${gt.runningBorder};${gt.runningPulseStroke};${gt.runningBorder}`, + ); + animStroke.setAttribute("dur", "1.5s"); + animStroke.setAttribute("repeatCount", "indefinite"); + shape.appendChild(animStroke); + + const animWidth = document.createElementNS(ns, "animate"); + animWidth.setAttribute("attributeName", "stroke-width"); + animWidth.setAttribute("values", "2;3.5;2"); + animWidth.setAttribute("dur", "1.5s"); + animWidth.setAttribute("repeatCount", "indefinite"); + shape.appendChild(animWidth); + } + for (const text of group.querySelectorAll("text")) { + text.setAttribute("fill", gt.runningText); + } + } else if (failedDotIds.has(nodeId)) { + for (const shape of group.querySelectorAll("ellipse, polygon, path")) { + shape.setAttribute("fill", gt.failedFill); + shape.setAttribute("stroke", gt.failedBorder); + } + for (const text of group.querySelectorAll("text")) { + text.setAttribute("fill", gt.failedText); + } + } else if (completedDotIds.has(nodeId)) { + for (const shape of group.querySelectorAll("ellipse, polygon, path")) { + shape.setAttribute("fill", gt.completedFill); + shape.setAttribute("stroke", gt.completedBorder); + } + for (const text of group.querySelectorAll("text")) { + text.setAttribute("fill", gt.completedText); + } + } + } + + return () => { + clearOpenTimer(); + for (const { target, type, listener } of listeners) { + target.removeEventListener(type, listener); + } + onHoverChange(null); + }; + }, [graphSvg, innerRef, onHoverChange, onStageClick, stages, svgRef, terminalOutcome]); +} diff --git a/apps/fabro-web/app/hooks/use-changed-files-tree-sync.ts b/apps/fabro-web/app/hooks/use-changed-files-tree-sync.ts new file mode 100644 index 000000000..5b68fb0b2 --- /dev/null +++ b/apps/fabro-web/app/hooks/use-changed-files-tree-sync.ts @@ -0,0 +1,72 @@ +import { useEffect, useRef } from "react"; + +import type { + FileTree as FileTreeModel, + GitStatusEntry, +} from "@pierre/trees"; + +/** + * Synchronizes Pierre's imperative changed-files tree model with React-owned + * file paths, git status, and selected-path state. Model mutations run after + * commit; no external subscription is created. + */ +export function useChangedFilesTreeSync({ + changedPaths, + changedPathsRef, + gitStatus, + model, + paths, + pendingSelectedPathRef, + selectedPath, + selectedPathRef, + selection, + syncSelection, +}: { + changedPaths: ReadonlySet; + changedPathsRef: { current: ReadonlySet }; + gitStatus: GitStatusEntry[]; + model: FileTreeModel; + paths: string[]; + pendingSelectedPathRef: { current: string | null }; + selectedPath: string | null; + selectedPathRef: { current: string | null }; + selection: readonly string[]; + syncSelection: ( + model: FileTreeModel, + selection: readonly string[], + selectedPath: string | null, + ) => void; +}) { + const didSyncModelRef = useRef(false); + + useEffect(() => { + if (!didSyncModelRef.current) { + didSyncModelRef.current = true; + return; + } + model.resetPaths(paths); + model.setGitStatus(gitStatus); + pendingSelectedPathRef.current = null; + const currentSelectedPath = selectedPathRef.current; + syncSelection( + model, + model.getSelectedPaths(), + currentSelectedPath && changedPathsRef.current.has(currentSelectedPath) + ? currentSelectedPath + : null, + ); + }, [changedPathsRef, gitStatus, model, paths, pendingSelectedPathRef, selectedPathRef, syncSelection]); + + useEffect(() => { + const pendingSelectedPath = pendingSelectedPathRef.current; + if (pendingSelectedPath === selectedPath) { + pendingSelectedPathRef.current = null; + } + const nextSelectedPath = pendingSelectedPath ?? selectedPath; + syncSelection( + model, + selection, + nextSelectedPath && changedPaths.has(nextSelectedPath) ? nextSelectedPath : null, + ); + }, [changedPaths, model, pendingSelectedPathRef, selectedPath, selection, syncSelection]); +} diff --git a/apps/fabro-web/app/hooks/use-data-updated-at.ts b/apps/fabro-web/app/hooks/use-data-updated-at.ts new file mode 100644 index 000000000..699beed43 --- /dev/null +++ b/apps/fabro-web/app/hooks/use-data-updated-at.ts @@ -0,0 +1,10 @@ +import { useMemo } from "react"; + +/** + * Captures a stable wall-clock timestamp for the current async data identity. + * The value is derived during render and stays stable until that identity + * changes. + */ +export function useDataUpdatedAt(data: T | null | undefined): number | null { + return useMemo(() => data != null ? Date.now() : null, [data]); +} diff --git a/apps/fabro-web/app/hooks/use-dot-language-ready.ts b/apps/fabro-web/app/hooks/use-dot-language-ready.ts new file mode 100644 index 000000000..3218001d2 --- /dev/null +++ b/apps/fabro-web/app/hooks/use-dot-language-ready.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; + +import { registerDotLanguage } from "../data/register-dot-language"; + +let dotLanguageRegistration: Promise | null = null; + +function ensureDotLanguageRegistered(): Promise { + dotLanguageRegistration ??= registerDotLanguage(); + return dotLanguageRegistration; +} + +/** + * Synchronizes React with the shared Pierre syntax highlighter's Graphviz DOT + * language registration. Registration is shared across mounts; cleanup only + * suppresses stale state updates because the highlighter registration is global. + */ +export function useDotLanguageReady(): boolean { + const [ready, setReady] = useState(false); + + useEffect(() => { + let cancelled = false; + void ensureDotLanguageRegistered().then(() => { + if (!cancelled) setReady(true); + }); + return () => { + cancelled = true; + }; + }, []); + + return ready; +} diff --git a/apps/fabro-web/app/hooks/use-file-tree-model.ts b/apps/fabro-web/app/hooks/use-file-tree-model.ts new file mode 100644 index 000000000..7101158d8 --- /dev/null +++ b/apps/fabro-web/app/hooks/use-file-tree-model.ts @@ -0,0 +1,16 @@ +import { useEffect } from "react"; + +import type { FileTree as FileTreeModel } from "@pierre/trees"; + +/** + * Synchronizes Pierre's imperative file-tree model with the latest path list. + * The model owns no subscription here, so no cleanup is required. + */ +export function useResetFileTreePaths( + model: FileTreeModel, + paths: readonly string[], +) { + useEffect(() => { + model.resetPaths(paths); + }, [model, paths]); +} diff --git a/apps/fabro-web/app/hooks/use-floating-tooltip-measurements.ts b/apps/fabro-web/app/hooks/use-floating-tooltip-measurements.ts new file mode 100644 index 000000000..68eafa836 --- /dev/null +++ b/apps/fabro-web/app/hooks/use-floating-tooltip-measurements.ts @@ -0,0 +1,55 @@ +import { useLayoutEffect, useRef, useState } from "react"; + +export type FloatingTooltipSize = { height: number; width: number }; + +function viewportSize(): FloatingTooltipSize { + return { height: window.innerHeight, width: window.innerWidth }; +} + +/** + * Synchronizes a floating tooltip with DOM layout measurements, ResizeObserver, + * and window resize events. Observers and listeners are disconnected on + * unmount. + */ +export function useFloatingTooltipMeasurements() { + const ref = useRef(null); + const [size, setSize] = useState({ height: 0, width: 0 }); + const [viewport, setViewport] = useState(() => + typeof window === "undefined" ? { height: 0, width: 0 } : viewportSize(), + ); + + useLayoutEffect(() => { + const node = ref.current; + if (!node) return; + + const updateSize = () => { + const next = node.getBoundingClientRect(); + setSize((prev) => + prev.height === next.height && prev.width === next.width + ? prev + : { height: next.height, width: next.width }, + ); + }; + const updateViewport = () => { + const next = viewportSize(); + setViewport((prev) => + prev.height === next.height && prev.width === next.width ? prev : next, + ); + }; + + updateSize(); + updateViewport(); + const resizeObserver = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(updateSize); + resizeObserver?.observe(node); + window.addEventListener("resize", updateViewport); + return () => { + resizeObserver?.disconnect(); + window.removeEventListener("resize", updateViewport); + }; + }, []); + + return { ref, size, viewport }; +} diff --git a/apps/fabro-web/app/hooks/use-focus-after-refresh.ts b/apps/fabro-web/app/hooks/use-focus-after-refresh.ts new file mode 100644 index 000000000..6a7e98792 --- /dev/null +++ b/apps/fabro-web/app/hooks/use-focus-after-refresh.ts @@ -0,0 +1,20 @@ +import { useEffect, useRef, type RefObject } from "react"; + +/** + * Synchronizes refresh completion with browser focus so keyboard users return to + * the refresh control. No cleanup is required because focus is a one-shot DOM + * operation and duplicate Strict Mode calls do not change persisted state. + */ +export function useFocusAfterRefreshCompletes( + refreshing: boolean, + targetRef: RefObject, +) { + const refreshingPrev = useRef(false); + + useEffect(() => { + if (refreshingPrev.current && !refreshing) { + targetRef.current?.focus({ preventScroll: true }); + } + refreshingPrev.current = refreshing; + }, [refreshing, targetRef]); +} diff --git a/apps/fabro-web/app/hooks/use-insights-query-runner.ts b/apps/fabro-web/app/hooks/use-insights-query-runner.ts new file mode 100644 index 000000000..019350947 --- /dev/null +++ b/apps/fabro-web/app/hooks/use-insights-query-runner.ts @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface QueryResult { + columns: string[]; + rows: Array>; + elapsed: number; + rowsRead: number; + bytesRead: number; + rowsReturned: number; +} + +function generateMockResult(sql: string): QueryResult { + const lowerSql = sql.toLowerCase(); + + if (lowerSql.includes("workflow_name") && lowerSql.includes("avg")) { + return { + columns: ["workflow_name", "avg_duration", "run_count"], + rows: [ + { workflow_name: "Expand Product", avg_duration: 342.5, run_count: 48 }, + { workflow_name: "Implement Feature", avg_duration: 287.3, run_count: 156 }, + { workflow_name: "Security Scan", avg_duration: 198.1, run_count: 312 }, + { workflow_name: "Fix Build", avg_duration: 145.7, run_count: 482 }, + { workflow_name: "Sync Drift", avg_duration: 89.2, run_count: 94 }, + { workflow_name: "Dependency Audit", avg_duration: 67.4, run_count: 201 }, + ], + elapsed: 0.531, + rowsRead: 5182366, + bytesRead: 357780000, + rowsReturned: 6, + }; + } + + if (lowerSql.includes("failure_rate") || lowerSql.includes("failed")) { + return { + columns: ["day", "failures", "total", "failure_rate"], + rows: Array.from({ length: 14 }, (_, i) => { + const d = new Date(); + d.setDate(d.getDate() - i); + const total = 80 + Math.floor(Math.random() * 60); + const failures = Math.floor(Math.random() * 15); + return { + day: d.toISOString().slice(0, 10), + failures, + total, + failure_rate: Math.round((1000 * failures) / total) / 10, + }; + }), + elapsed: 0.287, + rowsRead: 2841092, + bytesRead: 198400000, + rowsReturned: 14, + }; + } + + return { + columns: ["repo", "runs", "total_additions", "total_deletions"], + rows: [ + { repo: "fabro-engine", runs: 482, total_additions: 28450, total_deletions: 12300 }, + { repo: "fabro-web", runs: 356, total_additions: 19200, total_deletions: 8900 }, + { repo: "fabro-cli", runs: 198, total_additions: 8700, total_deletions: 4200 }, + { repo: "fabro-docs", runs: 145, total_additions: 12100, total_deletions: 3400 }, + { repo: "fabro-sdk", runs: 89, total_additions: 5600, total_deletions: 2100 }, + { repo: "fabro-infra", runs: 67, total_additions: 3200, total_deletions: 1800 }, + { repo: "fabro-actions", runs: 42, total_additions: 2100, total_deletions: 980 }, + { repo: "fabro-proto", runs: 28, total_additions: 1400, total_deletions: 650 }, + ], + elapsed: 0.148, + rowsRead: 1204588, + bytesRead: 89200000, + rowsReturned: 8, + }; +} + +/** + * Synchronizes the mock Insights query runner with the browser timer queue. + * Starting a new run clears any pending timer, and the active timer is cleared + * on unmount so stale completions cannot update React state. + */ +export function useInsightsQueryRunner(initialSql: string) { + const [result, setResult] = useState(() => + generateMockResult(initialSql), + ); + const [isRunning, setIsRunning] = useState(false); + const runRequestIdRef = useRef(0); + const runTimeoutRef = useRef | null>(null); + + const clearPendingRun = useCallback(() => { + if (runTimeoutRef.current !== null) { + clearTimeout(runTimeoutRef.current); + runTimeoutRef.current = null; + } + }, []); + + const runQuery = useCallback((sql: string) => { + const requestId = runRequestIdRef.current + 1; + runRequestIdRef.current = requestId; + clearPendingRun(); + setIsRunning(true); + const delay = 200 + Math.random() * 400; + runTimeoutRef.current = setTimeout(() => { + if (runRequestIdRef.current !== requestId) return; + runTimeoutRef.current = null; + setResult(generateMockResult(sql)); + setIsRunning(false); + }, delay); + }, [clearPendingRun]); + + useEffect(() => { + return () => { + runRequestIdRef.current += 1; + clearPendingRun(); + }; + }, [clearPendingRun]); + + return { result, isRunning, runQuery }; +} diff --git a/apps/fabro-web/app/hooks/use-install-effects.ts b/apps/fabro-web/app/hooks/use-install-effects.ts new file mode 100644 index 000000000..eed8c9a74 --- /dev/null +++ b/apps/fabro-web/app/hooks/use-install-effects.ts @@ -0,0 +1,128 @@ +import { useEffect, useRef, type Dispatch, type SetStateAction } from "react"; + +import { + type InstallFinishResponse, + persistInstallToken, +} from "../install-api"; +import { shouldRedirectAfterHealthPoll } from "../install-flow"; +import { + consumeInstallGithubErrorFromUrl, + consumeInstallTokenFromUrl, + shouldConsumeInstallGithubErrorForPath, +} from "../mode"; + +type InstallGithubCallbackAction = + | { type: "saveErrorChanged"; message: string | null }; + +type InstallRestartPollingAction = + | { type: "timedOutChanged"; timedOut: boolean }; + +/** + * Synchronizes install mode with the browser URL and sessionStorage. A token in + * the URL is persisted, promoted into React state, and scrubbed from history on + * mount; there is no resource to clean up. + */ +export function useInstallTokenFromUrl({ + setInstallToken, +}: { + setInstallToken: Dispatch>; +}) { + useEffect(() => { + const { token, sanitizedUrl } = consumeInstallTokenFromUrl(window.location.href); + if (!token) return; + + persistInstallToken(token); + setInstallToken(token); + window.history.replaceState(window.history.state, "", sanitizedUrl); + }, [setInstallToken]); +} + +/** + * Synchronizes GitHub App callback errors from the browser URL into the install + * state machine. The error query parameter is scrubbed from history after it is + * consumed; there is no resource to clean up. + */ +export function useInstallGithubCallbackError({ + dispatchInstall, + pathname, +}: { + dispatchInstall: (action: InstallGithubCallbackAction) => void; + pathname: string; +}) { + const consumedErrorPathRef = useRef(null); + + useEffect(() => { + if (shouldConsumeInstallGithubErrorForPath(pathname)) { + const { error, sanitizedUrl } = consumeInstallGithubErrorFromUrl(window.location.href); + if (error) { + consumedErrorPathRef.current = pathname; + dispatchInstall({ type: "saveErrorChanged", message: error }); + window.history.replaceState(window.history.state, "", sanitizedUrl); + return; + } + if (consumedErrorPathRef.current === pathname) { + return; + } + } + consumedErrorPathRef.current = null; + dispatchInstall({ type: "saveErrorChanged", message: null }); + }, [dispatchInstall, pathname]); +} + +/** + * Synchronizes install finishing with browser timers, fetch health polling, and + * `window.location`. The deadline timer, polling interval, and in-flight fetch + * are cancelled when finishing stops or the component unmounts. + */ +export function useInstallRestartHealthPolling({ + dispatchInstall, + finishState, +}: { + dispatchInstall: (action: InstallRestartPollingAction) => void; + finishState: InstallFinishResponse | null; +}) { + useEffect(() => { + if (!finishState) return; + + dispatchInstall({ type: "timedOutChanged", timedOut: false }); + const deadline = window.setTimeout(() => { + dispatchInstall({ type: "timedOutChanged", timedOut: true }); + }, 30_000); + + const controller = new AbortController(); + let inFlight = false; + const poll = async () => { + if (inFlight || controller.signal.aborted) return; + inFlight = true; + try { + const response = await fetch("/health", { signal: controller.signal }); + const body = response.ok + ? ((await response.json()) as { mode?: string }) + : undefined; + if ( + shouldRedirectAfterHealthPoll({ + kind: "response", + ok: response.ok, + mode: body?.mode, + }) + ) { + window.location.href = finishState.restart_url; + } + } catch { + if (controller.signal.aborted) return; + if (shouldRedirectAfterHealthPoll({ kind: "error" })) { + window.location.href = finishState.restart_url; + } + } finally { + inFlight = false; + } + }; + const interval = window.setInterval(poll, 2_000); + + return () => { + controller.abort(); + window.clearTimeout(deadline); + window.clearInterval(interval); + }; + }, [dispatchInstall, finishState]); +} diff --git a/apps/fabro-web/app/hooks/use-minimum-refresh-spinner.ts b/apps/fabro-web/app/hooks/use-minimum-refresh-spinner.ts new file mode 100644 index 000000000..476d35446 --- /dev/null +++ b/apps/fabro-web/app/hooks/use-minimum-refresh-spinner.ts @@ -0,0 +1,30 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +/** + * Synchronizes a user-triggered refresh affordance with the browser timer queue. + * Any pending minimum-duration timer is cleared before restart and on unmount. + */ +export function useMinimumRefreshSpinner(durationMs: number) { + const timerRef = useRef | null>(null); + const [active, setActive] = useState(false); + + const clear = useCallback(() => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const start = useCallback(() => { + clear(); + setActive(true); + timerRef.current = setTimeout(() => { + setActive(false); + timerRef.current = null; + }, durationMs); + }, [clear, durationMs]); + + useEffect(() => clear, [clear]); + + return { active, start }; +} diff --git a/apps/fabro-web/app/hooks/use-pending-chat-autoresponse.ts b/apps/fabro-web/app/hooks/use-pending-chat-autoresponse.ts new file mode 100644 index 000000000..745e2436c --- /dev/null +++ b/apps/fabro-web/app/hooks/use-pending-chat-autoresponse.ts @@ -0,0 +1,30 @@ +import { useEffect, useRef } from "react"; + +/** + * Synchronizes a pending scripted chat response with assistant-ui's imperative + * runtime. There is no resource to clean up; duplicate Strict Mode calls are + * harmless because the local ref dedupes a mount cycle and the chat store flag + * dedupes remounts. + */ +export function usePendingChatAutoresponse({ + chatId, + pendingResponse, + consumePendingResponse, + startRun, +}: { + chatId: string; + pendingResponse: boolean; + consumePendingResponse: (chatId: string) => void; + startRun: () => void; +}) { + const startRunRef = useRef(startRun); + startRunRef.current = startRun; + const didStartRef = useRef(false); + + useEffect(() => { + if (!pendingResponse || didStartRef.current) return; + didStartRef.current = true; + consumePendingResponse(chatId); + startRunRef.current(); + }, [chatId, consumePendingResponse, pendingResponse]); +} diff --git a/apps/fabro-web/app/hooks/use-rendered-viz-diagram.ts b/apps/fabro-web/app/hooks/use-rendered-viz-diagram.ts new file mode 100644 index 000000000..9733f335e --- /dev/null +++ b/apps/fabro-web/app/hooks/use-rendered-viz-diagram.ts @@ -0,0 +1,54 @@ +import { useEffect, useState } from "react"; + +/** + * Synchronizes a DOT source with the imperative @viz-js SVG renderer and a DOM + * container. Async renders are ignored after identity changes or unmount. + */ +export function useRenderedVizDiagram({ + buildDot, + innerRef, + identity, + onRenderStart, + prepareSvg, + svgRef, +}: { + buildDot: (identity: TIdentity) => string; + innerRef: { current: HTMLDivElement | null }; + identity: TIdentity; + onRenderStart?: () => void; + prepareSvg?: (svg: SVGSVGElement) => void; + svgRef: { current: SVGSVGElement | null }; +}): string | null { + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function render() { + setError(null); + onRenderStart?.(); + const { instance } = await import("@viz-js/viz"); + const viz = await instance(); + if (cancelled) return; + + try { + const svg = viz.renderSVGElement(buildDot(identity)); + prepareSvg?.(svg); + + svgRef.current = svg; + if (innerRef.current) { + innerRef.current.replaceChildren(svg); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to render diagram"); + } + } + + void render(); + return () => { + cancelled = true; + }; + }, [buildDot, identity, innerRef, onRenderStart, prepareSvg, svgRef]); + + return error; +} diff --git a/apps/fabro-web/app/hooks/use-run-file-deep-link.ts b/apps/fabro-web/app/hooks/use-run-file-deep-link.ts new file mode 100644 index 000000000..c85daeae9 --- /dev/null +++ b/apps/fabro-web/app/hooks/use-run-file-deep-link.ts @@ -0,0 +1,47 @@ +import { useEffect, useRef } from "react"; + +import type { PaginatedRunFileList } from "@qltysh/fabro-api-client"; +import type { ToastInput } from "../components/toast"; + +/** + * Synchronizes the run-files URL hash with rendered file-row DOM focus and the + * toast system. Missing-file toasts are deduped by key, and no persistent + * browser resource is created. + */ +export function useRunFileDeepLinkFocus({ + data, + hashFile, + rowId, + resolveToast, + push, +}: { + data: PaginatedRunFileList | null; + hashFile: string | null; + rowId: (path: string) => string; + resolveToast: ( + hashFile: string | null, + data: PaginatedRunFileList | null, + ) => { key: string; message: string } | null; + push: (toast: ToastInput) => string; +}) { + const lastToastRef = useRef(null); + + useEffect(() => { + const toast = resolveToast(hashFile, data); + if (toast) { + if (lastToastRef.current !== toast.key) { + push({ message: toast.message, autoDismissMs: 5000 }); + lastToastRef.current = toast.key; + } + return; + } + + lastToastRef.current = null; + if (!hashFile || !data) return; + const el = document.getElementById(rowId(hashFile)); + if (el) { + el.scrollIntoView({ block: "start", behavior: "smooth" }); + el.focus({ preventScroll: true }); + } + }, [data, hashFile, push, resolveToast, rowId]); +} diff --git a/apps/fabro-web/app/hooks/use-run-toasts.ts b/apps/fabro-web/app/hooks/use-run-toasts.ts index 58b8ba395..0badcdb87 100644 --- a/apps/fabro-web/app/hooks/use-run-toasts.ts +++ b/apps/fabro-web/app/hooks/use-run-toasts.ts @@ -7,6 +7,10 @@ import type { MutateFn } from "../lib/sse"; const NOOP_MUTATE = (() => undefined) as MutateFn; const DEDUPE_WINDOW = 256; +/** + * Synchronizes toast notifications with a run-scoped SSE stream. Changing + * `runId` resubscribes, and the active subscription is closed on unmount. + */ export function useRunToasts(runId: string | undefined) { const { push } = useToast(); const seenEventIdsRef = useRef(new Set()); diff --git a/apps/fabro-web/app/hooks/use-terminal-session.ts b/apps/fabro-web/app/hooks/use-terminal-session.ts new file mode 100644 index 000000000..c8476f4e8 --- /dev/null +++ b/apps/fabro-web/app/hooks/use-terminal-session.ts @@ -0,0 +1,207 @@ +import { useEffect, useRef, type Dispatch, type RefObject, type SetStateAction } from "react"; +import type { Terminal as XtermTerminal } from "@xterm/xterm"; +import type { FitAddon as XtermFitAddon } from "@xterm/addon-fit"; + +import { + buildTerminalWebSocketUrl, + parseTerminalServerMessage, +} from "../components/terminal-view-helpers"; + +export type ConnectionStatus = "connecting" | "ready" | "closed" | "error"; + +export type TerminalConnectionError = { + message: string; + recoverable: boolean; +}; + +export const TERMINAL_BACKGROUND = "#05080F"; + +// Pin the cell to a whole-pixel height so xterm's fit math stays exact. +// fontSize × lineHeight = 13 × (19/13) = 19px → no sub-pixel rounding, +// no bottom-row clipping. +const TERMINAL_FONT_SIZE = 13; +const TERMINAL_CELL_HEIGHT_PX = 19; +const TERMINAL_LINE_HEIGHT = TERMINAL_CELL_HEIGHT_PX / TERMINAL_FONT_SIZE; + +const TERMINAL_THEME = { + background: TERMINAL_BACKGROUND, + foreground: "#E6EDF3", + cursor: "#7AC4E5", + cursorAccent: TERMINAL_BACKGROUND, + selectionBackground: "#1F4F73", + + black: TERMINAL_BACKGROUND, + red: "#FF6B6B", + green: "#5EE6A8", + yellow: "#FFC857", + blue: "#82AAFF", + magenta: "#C792EA", + cyan: "#7AC4E5", + white: "#D5DCE3", + + brightBlack: "#4B5563", + brightRed: "#FF8B8B", + brightGreen: "#85F5C2", + brightYellow: "#FFD98A", + brightBlue: "#A4C4FF", + brightMagenta: "#E0B6FF", + brightCyan: "#A8DFF5", + brightWhite: "#FFFFFF", +}; + +function sendResize(socket: WebSocket | null, terminal: XtermTerminal | null) { + if (!socket || socket.readyState !== WebSocket.OPEN || !terminal) return; + socket.send(JSON.stringify({ + type: "resize", + cols: terminal.cols, + rows: terminal.rows, + })); +} + +/** + * Synchronizes a mounted DOM node with xterm, its FitAddon, ResizeObserver, and + * the run terminal WebSocket. All listeners, observers, sockets, and xterm + * disposables are cleaned up before reconnect and on unmount. + */ +export function useTerminalSession({ + connectionKey, + runId, + setError, + setStatus, + terminalEl, +}: { + connectionKey: number; + runId: string; + setError: Dispatch>; + setStatus: Dispatch>; + terminalEl: RefObject; +}) { + const terminalRef = useRef(null); + const fitRef = useRef(null); + const socketRef = useRef(null); + + useEffect(() => { + if (!terminalEl.current) return undefined; + + let disposed = false; + let resizeObserver: ResizeObserver | null = null; + const textEncoder = new TextEncoder(); + const disposables: Array<{ dispose: () => void }> = []; + + async function connect() { + setStatus("connecting"); + setError(null); + + const [{ Terminal }, { FitAddon }] = await Promise.all([ + import("@xterm/xterm"), + import("@xterm/addon-fit"), + ]); + if (disposed || !terminalEl.current) return; + + const terminal = new Terminal({ + cursorBlink: true, + convertEol: true, + fontFamily: "\"JetBrains Mono\", ui-monospace, monospace", + fontSize: TERMINAL_FONT_SIZE, + lineHeight: TERMINAL_LINE_HEIGHT, + scrollback: 5000, + theme: TERMINAL_THEME, + }); + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + terminal.open(terminalEl.current); + fitAddon.fit(); + terminal.focus(); + terminalRef.current = terminal; + fitRef.current = fitAddon; + + const socket = new WebSocket(buildTerminalWebSocketUrl(window.location, runId)); + socket.binaryType = "arraybuffer"; + socketRef.current = socket; + + disposables.push(terminal.onData((data) => { + if (socket.readyState === WebSocket.OPEN) { + socket.send(textEncoder.encode(data)); + } + })); + + const handleOpen = () => { + sendResize(socket, terminal); + }; + const handleMessage = (event: MessageEvent) => { + if (typeof event.data === "string") { + const message = parseTerminalServerMessage(event.data); + if (!message) return; + if (message.type === "ready") { + setStatus("ready"); + return; + } + if (message.type === "closed") { + setStatus("closed"); + return; + } + setStatus("error"); + setError({ + message: message.message ?? "Terminal session failed.", + recoverable: false, + }); + return; + } + const bytes = event.data instanceof ArrayBuffer + ? new Uint8Array(event.data) + : event.data; + terminal.write(bytes); + }; + const handleClose = () => { + setStatus((current) => current === "error" ? current : "closed"); + }; + const handleError = () => { + setStatus("error"); + setError({ + message: "Terminal WebSocket connection failed.", + recoverable: true, + }); + }; + socket.addEventListener("open", handleOpen); + socket.addEventListener("message", handleMessage); + socket.addEventListener("close", handleClose); + socket.addEventListener("error", handleError); + disposables.push({ + dispose: () => { + socket.removeEventListener("open", handleOpen); + socket.removeEventListener("message", handleMessage); + socket.removeEventListener("close", handleClose); + socket.removeEventListener("error", handleError); + }, + }); + + resizeObserver = new ResizeObserver(() => { + fitAddon.fit(); + sendResize(socket, terminal); + }); + resizeObserver.observe(terminalEl.current); + + if (typeof document !== "undefined" && document.fonts?.ready) { + void document.fonts.ready.then(() => { + if (disposed) return; + fitAddon.fit(); + sendResize(socket, terminal); + }); + } + } + + void connect(); + + return () => { + disposed = true; + resizeObserver?.disconnect(); + for (const disposable of disposables) disposable.dispose(); + socketRef.current?.send(JSON.stringify({ type: "close" })); + socketRef.current?.close(); + socketRef.current = null; + terminalRef.current?.dispose(); + terminalRef.current = null; + fitRef.current = null; + }; + }, [connectionKey, runId, setError, setStatus, terminalEl]); +} diff --git a/apps/fabro-web/app/install-app.test.tsx b/apps/fabro-web/app/install-app.test.tsx index 07025a3fa..b3ba590ad 100644 --- a/apps/fabro-web/app/install-app.test.tsx +++ b/apps/fabro-web/app/install-app.test.tsx @@ -1,5 +1,6 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; import type { AxiosAdapter } from "axios"; +import { StrictMode } from "react"; import { MemoryRouter, Route, Routes } from "react-router"; import TestRenderer, { act } from "react-test-renderer"; @@ -209,11 +210,13 @@ describe("InstallApp", () => { let renderer: TestRenderer.ReactTestRenderer | null = null; await act(async () => { renderer = TestRenderer.create( - - - } /> - - , + + + + } /> + + + , ); }); diff --git a/apps/fabro-web/app/install-app.tsx b/apps/fabro-web/app/install-app.tsx index 47c63877a..8cf9b6ff1 100644 --- a/apps/fabro-web/app/install-app.tsx +++ b/apps/fabro-web/app/install-app.tsx @@ -1,11 +1,10 @@ -import { startTransition, useEffect, useMemo, useReducer, useRef, useState } from "react"; +import { useMemo, useReducer, useRef, useState } from "react"; import type { FormEvent, ReactNode, Ref } from "react"; import { Link, Navigate, useLocation, useNavigate, - type NavigateFunction, } from "react-router"; import { ArrowLeftIcon, @@ -43,12 +42,7 @@ import { testInstallSandbox, } from "./install-api"; import { INSTALL_PROVIDERS } from "./install-config"; -import { shouldRedirectAfterHealthPoll } from "./install-flow"; -import { - consumeInstallGithubErrorFromUrl, - consumeInstallTokenFromUrl, - shouldConsumeInstallGithubErrorForPath, -} from "./mode"; +import { useInstallSessionQuery } from "./install-query"; import { CopyButton, ErrorMessage, @@ -57,6 +51,12 @@ import { SECONDARY_BUTTON_CLASS, } from "./components/ui"; import { LoadingState } from "./components/state"; +import { + useInstallGithubCallbackError, + useInstallRestartHealthPolling, + useInstallTokenFromUrl, +} from "./hooks/use-install-effects"; +import { consumeInstallTokenFromUrl } from "./mode"; const INSTALL_STEPS = [ { id: "welcome", label: "Welcome", href: "/install/welcome" }, @@ -77,9 +77,9 @@ type GithubOwnerKind = "personal" | "org"; type SessionState = | { status: "idle" } - | { status: "loading" } - | { status: "error"; message: string } - | { status: "ready"; data: InstallSessionResponse }; + | { status: "loading"; token: string } + | { status: "error"; token: string | null; message: string } + | { status: "ready"; token: string; data: InstallSessionResponse }; type TokenForm = { token: string; username: string }; @@ -134,9 +134,8 @@ type InstallState = { type InstallAction = | { type: "manualTokenChanged"; value: string } | { type: "sessionCleared" } - | { type: "sessionRequested" } - | { type: "sessionReady"; session: InstallSessionResponse } - | { type: "sessionFailed"; message: string } + | { type: "sessionReady"; token: string; session: InstallSessionResponse } + | { type: "sessionFailed"; token: string | null; message: string } | { type: "saveErrorChanged"; message: string | null } | { type: "submittingChanged"; submitting: boolean } | { type: "timedOutChanged"; timedOut: boolean } @@ -177,6 +176,7 @@ function initialInstallState(): InstallState { function hydrateInstallState( state: InstallState, + token: string, session: InstallSessionResponse, ): InstallState { let githubStrategy = state.githubStrategy; @@ -200,7 +200,7 @@ function hydrateInstallState( return { ...state, - sessionState: { status: "ready", data: session }, + sessionState: { status: "ready", token, data: session }, canonicalUrl: state.canonicalUrl || session.server?.canonical_url || @@ -220,12 +220,10 @@ function installReducer(state: InstallState, action: InstallAction): InstallStat return { ...state, manualToken: action.value }; case "sessionCleared": return { ...state, sessionState: { status: "idle" } }; - case "sessionRequested": - return { ...state, sessionState: { status: "loading" } }; case "sessionReady": - return hydrateInstallState(state, action.session); + return hydrateInstallState(state, action.token, action.session); case "sessionFailed": - return { ...state, sessionState: { status: "error", message: action.message } }; + return { ...state, sessionState: { status: "error", token: action.token, message: action.message } }; case "saveErrorChanged": return { ...state, saveError: action.message }; case "submittingChanged": @@ -286,10 +284,55 @@ function installReducer(state: InstallState, action: InstallAction): InstallStat } } +function installSessionErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Install session failed"; +} + +function sessionStateForInstallToken( + installToken: string | null, + sessionState: SessionState, + queryError: unknown, +): SessionState { + if (!installToken) { + return sessionState.status === "error" && sessionState.token === null + ? sessionState + : { status: "idle" }; + } + + if ( + (sessionState.status === "ready" || sessionState.status === "error") && + sessionState.token === installToken + ) { + return sessionState; + } + + if (queryError) { + return { + status: "error", + token: installToken, + message: installSessionErrorMessage(queryError), + }; + } + + return { status: "loading", token: installToken }; +} + +function readInitialInstallToken(): string | null { + const stored = readStoredInstallToken(); + if (stored) return stored; + if (typeof window === "undefined") return null; + return consumeInstallTokenFromUrl(window.location.href).token; +} + +/** + * Coordinates install-mode browser integrations: token/error URL scrubbing, + * install-session query state, and restart health polling. Timers, intervals, + * and in-flight requests are cancelled when their install identity changes. + */ function useInstallController() { const { pathname } = useLocation(); const [installToken, setInstallToken] = useState(() => - readStoredInstallToken(), + readInitialInstallToken(), ); const [installState, dispatchInstall] = useReducer( installReducer, @@ -297,128 +340,54 @@ function useInstallController() { initialInstallState, ); const { finishState } = installState; + const installSessionQuery = useInstallSessionQuery(installToken, { + onSuccess: (session) => { + if (!installToken) return; + dispatchInstall({ type: "sessionReady", token: installToken, session }); + }, + onError: (error) => { + dispatchInstall({ + type: "sessionFailed", + token: installToken, + message: installSessionErrorMessage(error), + }); + }, + }); - useEffect(() => { - const { token, sanitizedUrl } = consumeInstallTokenFromUrl(window.location.href); - if (!token) return; - - persistInstallToken(token); - // react-doctor-disable-next-line react-doctor/no-initialize-state -- The token is persisted and scrubbed from the URL after the client mounts. - setInstallToken(token); - window.history.replaceState(window.history.state, "", sanitizedUrl); - }, []); - - useEffect(() => { - if (shouldConsumeInstallGithubErrorForPath(pathname)) { - const { error, sanitizedUrl } = consumeInstallGithubErrorFromUrl(window.location.href); - if (error) { - dispatchInstall({ type: "saveErrorChanged", message: error }); - window.history.replaceState(window.history.state, "", sanitizedUrl); - return; - } - } - dispatchInstall({ type: "saveErrorChanged", message: null }); - }, [pathname]); - - useEffect(() => { + useInstallTokenFromUrl({ setInstallToken }); + useInstallGithubCallbackError({ dispatchInstall, pathname }); + useInstallRestartHealthPolling({ dispatchInstall, finishState }); + const sessionState = sessionStateForInstallToken( + installToken, + installState.sessionState, + installSessionQuery.error, + ); + const controllerState = + sessionState === installState.sessionState + ? installState + : { ...installState, sessionState }; + const refreshInstallSession = async () => { if (!installToken) { - dispatchInstall({ type: "sessionCleared" }); - return; + throw new Error("Install token is required to refresh the session."); } + const nextSession = await getInstallSession(installToken); + dispatchInstall({ + type: "sessionReady", + token: installToken, + session: nextSession, + }); + await installSessionQuery.mutate(nextSession, { revalidate: false }); + return nextSession; + }; - let cancelled = false; - dispatchInstall({ type: "sessionRequested" }); - getInstallSession(installToken) - .then((nextSession) => { - if (cancelled) return; - dispatchInstall({ type: "sessionReady", session: nextSession }); - }) - .catch((error) => { - if (cancelled) return; - dispatchInstall({ - type: "sessionFailed", - message: error instanceof Error ? error.message : "Install session failed", - }); - }); - - return () => { - cancelled = true; - }; - }, [installToken]); - - // react-doctor-disable-next-line react-doctor/no-fetch-in-effect -- This is install-mode restart polling, not cacheable app data. - useEffect(() => { - if (!finishState) return; - - dispatchInstall({ type: "timedOutChanged", timedOut: false }); - const deadline = window.setTimeout(() => { - dispatchInstall({ type: "timedOutChanged", timedOut: true }); - }, 30_000); - - const controller = new AbortController(); - let inFlight = false; - const poll = async () => { - if (inFlight || controller.signal.aborted) return; - inFlight = true; - try { - // react-doctor-disable-next-line react-doctor/no-fetch-in-effect -- This health probe is tied to install restart polling, not cacheable app data. - const response = await fetch("/health", { signal: controller.signal }); - const body = response.ok - ? ((await response.json()) as { mode?: string }) - : undefined; - if ( - shouldRedirectAfterHealthPoll({ - kind: "response", - ok: response.ok, - mode: body?.mode, - }) - ) { - window.location.href = finishState.restart_url; - } - } catch { - if (controller.signal.aborted) return; - if (shouldRedirectAfterHealthPoll({ kind: "error" })) { - window.location.href = finishState.restart_url; - } - } finally { - inFlight = false; - } - }; - const interval = window.setInterval(poll, 2_000); - - return () => { - controller.abort(); - window.clearTimeout(deadline); - window.clearInterval(interval); - }; - }, [finishState]); - - return { pathname, installToken, setInstallToken, installState, dispatchInstall }; -} - -function useInstallRootRedirect({ - installToken, - session, - finishState, - pathname, - navigate, -}: { - installToken: string | null; - session: InstallSessionResponse | null; - finishState: FinishState; - pathname: string; - navigate: NavigateFunction; -}) { - // react-doctor-disable-next-line react-doctor/no-effect-chain -- Navigation waits for the async install session before leaving the token/root entry route. - useEffect(() => { - if (!installToken || !session) return; - // react-doctor-disable-next-line react-doctor/no-event-handler -- This redirects from root/install exactly once after the async session becomes available. - if ((pathname === "/" || pathname === "/install") && !finishState) { - startTransition(() => { - navigate("/install/welcome", { replace: true }); - }); - } - }, [finishState, installToken, pathname, navigate, session]); + return { + pathname, + installToken, + setInstallToken, + installState: controllerState, + dispatchInstall, + refreshInstallSession, + }; } export default function InstallApp() { @@ -429,6 +398,7 @@ export default function InstallApp() { setInstallToken, installState, dispatchInstall, + refreshInstallSession, } = useInstallController(); const { sessionState, @@ -447,8 +417,6 @@ export default function InstallApp() { } = installState; const session = sessionState.status === "ready" ? sessionState.data : null; - useInstallRootRedirect({ installToken, session, finishState, pathname, navigate }); - const currentStep = useMemo( () => STEPPER_STEPS.find((step) => pathname.startsWith(step.href))?.id ?? @@ -474,6 +442,7 @@ export default function InstallApp() { if (!nextToken) { dispatchInstall({ type: "sessionFailed", + token: null, message: "Paste the install token from the server logs.", }); return; @@ -497,8 +466,7 @@ export default function InstallApp() { try { await args.action(); if (args.next) { - const nextSession = await getInstallSession(installToken); - dispatchInstall({ type: "sessionReady", session: nextSession }); + await refreshInstallSession(); navigate(args.next); } } catch (error) { @@ -528,8 +496,8 @@ export default function InstallApp() { ); } - // Covers both sessionState "loading" AND the brief "idle" window between - // the initial render and the session-fetch useEffect. Without this guard, + // Covers both sessionState "loading" AND the brief "idle" window before the + // install session query reports data. Without this guard, // screens like GithubAppDoneScreen see `session == null` and navigate away // before the first fetch finishes — trapping the user in a redirect loop. if (!session) { @@ -540,6 +508,10 @@ export default function InstallApp() { ); } + if ((pathname === "/" || pathname === "/install") && !finishState) { + return ; + } + if (finishState && pathname !== "/install/finishing") { return ; } diff --git a/apps/fabro-web/app/install-query.ts b/apps/fabro-web/app/install-query.ts new file mode 100644 index 000000000..6cfca946a --- /dev/null +++ b/apps/fabro-web/app/install-query.ts @@ -0,0 +1,31 @@ +import useSWR, { type SWRConfiguration } from "swr"; + +import { getInstallSession, type InstallSessionResponse } from "./install-api"; + +type InstallSessionKey = readonly ["install", "session", string]; + +function installSessionKey(token: string | null): InstallSessionKey | null { + return token ? ["install", "session", token] : null; +} + +/** + * Reads the install session through SWR so server state is owned by the query + * layer instead of a component effect. Revalidation is explicit because install + * setup writes refresh the session from their submit path. + */ +export function useInstallSessionQuery( + token: string | null, + options: SWRConfiguration = {}, +) { + return useSWR( + installSessionKey(token), + ([, , currentToken]) => getInstallSession(currentToken), + { + dedupingInterval: 0, + revalidateOnFocus: false, + revalidateOnReconnect: false, + shouldRetryOnError: false, + ...options, + }, + ); +} diff --git a/apps/fabro-web/app/layouts/app-shell.tsx b/apps/fabro-web/app/layouts/app-shell.tsx index ca0cbe417..d4749d485 100644 --- a/apps/fabro-web/app/layouts/app-shell.tsx +++ b/apps/fabro-web/app/layouts/app-shell.tsx @@ -15,7 +15,6 @@ import { Link, Outlet, useLocation, useMatches } from "react-router"; import { Toaster } from "sonner"; import { ErrorState } from "../components/state"; import { TooltipProvider } from "../components/ui"; -import { AskFabroLayoutProvider, useAskFabroLayout } from "../lib/ask-fabro-layout"; import { DemoModeProvider } from "../lib/demo-mode"; import { useAuthMe } from "../lib/queries"; import { allNavigation, getVisibleNavigation } from "./navigation"; @@ -65,7 +64,6 @@ export default function AppShell() { return ( -
)} - ); @@ -269,15 +266,16 @@ function ShellMain({ fullHeight: boolean; maxWidth: string; }) { - const { sidebarWidth, isResizing } = useAskFabroLayout(); return (
{ }); describe("stageArtifactDownloadUrl", () => { - test("builds the download href through generated client metadata", async () => { - await expect( + test("builds the escaped download href", () => { + expect( stageArtifactDownloadUrl("run 1", "stage@1", "logs/output.txt", 2), - ).resolves.toBe( + ).toBe( "/api/v1/runs/run%201/stages/stage%401/artifacts/download?filename=logs%2Foutput.txt&retry=2", ); }); diff --git a/apps/fabro-web/app/lib/api-client.ts b/apps/fabro-web/app/lib/api-client.ts index b86d0fc88..feef94682 100644 --- a/apps/fabro-web/app/lib/api-client.ts +++ b/apps/fabro-web/app/lib/api-client.ts @@ -12,7 +12,6 @@ import { InstallApi, ModelsApi, RunInternalsApi, - RunInternalsApiAxiosParamCreator, RunOutputsApi, RunsApi, SecretsApi, @@ -385,14 +384,17 @@ export function requestSignalOptions(request?: Request): RawAxiosRequestConfig { return request?.signal ? { signal: request.signal } : {}; } -export async function stageArtifactDownloadUrl( +export function stageArtifactDownloadUrl( id: string, stageId: string, filename: string, retry: number, -): Promise { - const requestArgs = await RunInternalsApiAxiosParamCreator( - generatedApiConfiguration, - ).getStageArtifact(id, stageId, filename, retry); - return `${generatedApiConfiguration.basePath ?? ""}${requestArgs.url}`; +): string { + const searchParams = new URLSearchParams({ + filename, + retry: String(retry), + }); + return `${generatedApiConfiguration.basePath ?? ""}/api/v1/runs/${ + encodeURIComponent(id) + }/stages/${encodeURIComponent(stageId)}/artifacts/download?${searchParams}`; } diff --git a/apps/fabro-web/app/lib/ask-fabro-layout.tsx b/apps/fabro-web/app/lib/ask-fabro-layout.tsx deleted file mode 100644 index 091fd3cad..000000000 --- a/apps/fabro-web/app/lib/ask-fabro-layout.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { createContext, use, useMemo, useState } from "react"; - -/** - * Layout coordination for the docked "Ask Fabro" sidebar. The run detail page - * owns the open/closed state and publishes the sidebar's current width here; - * the app shell reads it and insets `
` by that amount so the page - * content shifts left instead of being covered by the fixed sidebar. - */ -interface AskFabroLayout { - /** Width in px the docked sidebar currently occupies; 0 when closed. */ - sidebarWidth: number; - setSidebarWidth: (width: number) => void; - /** - * True while the user is dragging the sidebar's resize handle. Consumers - * that animate off `sidebarWidth` drop their transition while this is set so - * the layout tracks the cursor instead of trailing it by the ease duration. - */ - isResizing: boolean; - setIsResizing: (resizing: boolean) => void; -} - -const NOOP_LAYOUT: AskFabroLayout = { - sidebarWidth: 0, - setSidebarWidth: () => {}, - isResizing: false, - setIsResizing: () => {}, -}; - -const AskFabroLayoutContext = createContext(NOOP_LAYOUT); - -export function AskFabroLayoutProvider({ - children, -}: { - children: React.ReactNode; -}) { - const [sidebarWidth, setSidebarWidth] = useState(0); - const [isResizing, setIsResizing] = useState(false); - const value = useMemo( - () => ({ sidebarWidth, setSidebarWidth, isResizing, setIsResizing }), - [sidebarWidth, isResizing], - ); - return ( - - {children} - - ); -} - -export function useAskFabroLayout(): AskFabroLayout { - return use(AskFabroLayoutContext); -} diff --git a/apps/fabro-web/app/lib/board-events.ts b/apps/fabro-web/app/lib/board-events.ts index 2117396db..fcbfd8fad 100644 --- a/apps/fabro-web/app/lib/board-events.ts +++ b/apps/fabro-web/app/lib/board-events.ts @@ -93,6 +93,10 @@ function boardRunKeys() { return runListCacheMatchers(); } +/** + * Synchronizes React/SWR with the shared board SSE stream. The subscription is + * closed before resubscribe and on unmount. + */ export function useBoardEvents() { const { mutate } = useSWRConfig(); diff --git a/apps/fabro-web/app/lib/live-events.ts b/apps/fabro-web/app/lib/live-events.ts index 72120e43a..b80717125 100644 --- a/apps/fabro-web/app/lib/live-events.ts +++ b/apps/fabro-web/app/lib/live-events.ts @@ -1,3 +1,4 @@ +import { useEffect, useRef } from "react"; import type { Key } from "swr"; import { @@ -63,3 +64,18 @@ export function subscribeToLiveEvents( }), }); } + +/** + * Synchronizes React with the shared live-events SSE stream. The subscription is + * closed before resubscribe and on unmount; `onEvent` sees the latest render. + */ +export function useLiveEventsSubscription( + onEvent: (payload: LiveEventPayload) => void, +) { + const onEventRef = useRef(onEvent); + onEventRef.current = onEvent; + + useEffect(() => { + return subscribeToLiveEvents((payload) => onEventRef.current(payload)); + }, []); +} diff --git a/apps/fabro-web/app/lib/mutations.test.ts b/apps/fabro-web/app/lib/mutations.test.ts index 0eed33ec4..e5b24b125 100644 --- a/apps/fabro-web/app/lib/mutations.test.ts +++ b/apps/fabro-web/app/lib/mutations.test.ts @@ -20,24 +20,8 @@ mock.module("swr/mutation", () => ({ default: useSWRMutationMock, })); -mock.module("./api-client", () => ({ - apiData: mock(), - authApi: {}, - humanInTheLoopApi: {}, - runsApi: {}, -})); - -mock.module("./run-actions", () => ({ - approveRun: mock(), - archiveRun: mock(), - cancelRun: mock(), - denyRun: mock(), - isLifecycleActionError: () => false, - retryRun: mock(), - unarchiveRun: mock(), -})); - const { useArchiveRun } = await import("./mutations"); +mock.restore(); beforeEach(() => { mutateMock.mockClear(); diff --git a/apps/fabro-web/app/lib/run-events.ts b/apps/fabro-web/app/lib/run-events.ts index 8efd40547..a9eb5879a 100644 --- a/apps/fabro-web/app/lib/run-events.ts +++ b/apps/fabro-web/app/lib/run-events.ts @@ -256,6 +256,10 @@ function stageIdFromPayload(payload: RunEventPayload): string | undefined { return typeof nodeId === "string" ? nodeId : undefined; } +/** + * Synchronizes React/SWR with a run-scoped SSE stream. Changing `runId` + * resubscribes, and the active subscription is closed on unmount. + */ export function useRunEvents(runId: string | undefined) { const { mutate } = useSWRConfig(); diff --git a/apps/fabro-web/app/lib/time.ts b/apps/fabro-web/app/lib/time.ts index 53b04aa75..6b739e867 100644 --- a/apps/fabro-web/app/lib/time.ts +++ b/apps/fabro-web/app/lib/time.ts @@ -1,4 +1,6 @@ -import { useEffect, useReducer } from "react"; +import { useReducer } from "react"; + +import { useInterval } from "../hooks/effects"; /** * Re-renders the calling component every `intervalMs` milliseconds while @@ -7,11 +9,7 @@ import { useEffect, useReducer } from "react"; */ export function useTickingNow(active: boolean, intervalMs = 1000): number { const [now, tick] = useReducer(() => Date.now(), undefined, Date.now); - useEffect(() => { - if (!active) return; - const interval = setInterval(tick, intervalMs); - return () => clearInterval(interval); - }, [active, intervalMs]); + useInterval(tick, intervalMs, active); return now; } diff --git a/apps/fabro-web/app/routes/ask-fabro.tsx b/apps/fabro-web/app/routes/ask-fabro.tsx index 81ced132c..aea117bee 100644 --- a/apps/fabro-web/app/routes/ask-fabro.tsx +++ b/apps/fabro-web/app/routes/ask-fabro.tsx @@ -36,6 +36,7 @@ export default function AskFabro() { runId="demo" width={width} onWidthChange={setWidth} + onResizeActiveChange={() => {}} />
); diff --git a/apps/fabro-web/app/routes/automation-definition.tsx b/apps/fabro-web/app/routes/automation-definition.tsx index e587ed354..d864e7ac6 100644 --- a/apps/fabro-web/app/routes/automation-definition.tsx +++ b/apps/fabro-web/app/routes/automation-definition.tsx @@ -1,25 +1,14 @@ -import { useEffect, useState } from "react"; import { useOutletContext, useParams } from "react-router"; import type { BundledLanguage } from "@pierre/diffs"; -import { registerDotLanguage } from "../data/register-dot-language"; import { workflowData, type WorkflowEntry } from "./automation-detail"; import { CollapsibleFile } from "../components/collapsible-file"; +import { useDotLanguageReady } from "../hooks/use-dot-language-ready"; export default function AutomationDefinition() { const { name } = useParams(); const context = useOutletContext<{ workflow?: WorkflowEntry } | null>(); const workflow = context?.workflow ?? workflowData[name ?? ""]; - const [dotReady, setDotReady] = useState(false); - - useEffect(() => { - let cancelled = false; - registerDotLanguage().then(() => { - if (!cancelled) setDotReady(true); - }); - return () => { - cancelled = true; - }; - }, []); + const dotReady = useDotLanguageReady(); if (workflow == null) { return

No settings found.

; diff --git a/apps/fabro-web/app/routes/automation-diagram.tsx b/apps/fabro-web/app/routes/automation-diagram.tsx index 55058d776..ae8a0ec0f 100644 --- a/apps/fabro-web/app/routes/automation-diagram.tsx +++ b/apps/fabro-web/app/routes/automation-diagram.tsx @@ -1,6 +1,7 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { ArrowDownIcon, ArrowRightIcon, MinusIcon, PlusIcon } from "@heroicons/react/20/solid"; import { graphTheme } from "../lib/graph-theme"; +import { useRenderedVizDiagram } from "../hooks/use-rendered-viz-diagram"; type Direction = "LR" | "TB"; @@ -71,38 +72,20 @@ export default function AutomationDiagram() { const containerRef = useRef(null); const innerRef = useRef(null); const svgRef = useRef(null); - const [error, setError] = useState(null); const [zoomIndex, setZoomIndex] = useState(DEFAULT_ZOOM_INDEX); const [direction, setDirection] = useState("LR"); const [pan, setPan] = useState({ x: 0, y: 0 }); const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null); const zoom = ZOOM_STEPS[zoomIndex]; - - useEffect(() => { - let cancelled = false; - - async function render() { - const { instance } = await import("@viz-js/viz"); - const viz = await instance(); - if (cancelled) return; - - try { - const svg = viz.renderSVGElement(buildDot(direction)); - stripGraphTitle(svg); - - svgRef.current = svg; - if (innerRef.current) { - innerRef.current.replaceChildren(svg); - } - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to render diagram"); - } - } - - setPan({ x: 0, y: 0 }); - render(); - return () => { cancelled = true; }; - }, [direction]); + const resetPan = useCallback(() => setPan({ x: 0, y: 0 }), []); + const error = useRenderedVizDiagram({ + buildDot, + identity: direction, + innerRef, + onRenderStart: resetPan, + prepareSvg: stripGraphTitle, + svgRef, + }); const onPointerDown = useCallback((e: React.PointerEvent) => { if ((e.target as HTMLElement).closest("button")) return; diff --git a/apps/fabro-web/app/routes/chats-detail.tsx b/apps/fabro-web/app/routes/chats-detail.tsx index def71da9c..e41bcc59d 100644 --- a/apps/fabro-web/app/routes/chats-detail.tsx +++ b/apps/fabro-web/app/routes/chats-detail.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from "react"; +import { useMemo, useRef } from "react"; import { useNavigate, useParams } from "react-router"; import { AssistantRuntimeProvider, @@ -15,6 +15,7 @@ import CustomComposer from "../components/chats/custom-composer"; import ToolFallback from "../components/chats/tool-fallback"; import { EmptyState } from "../components/state"; import type { Chat, ChatMessage } from "../lib/chats-types"; +import { usePendingChatAutoresponse } from "../hooks/use-pending-chat-autoresponse"; // AppShell handle lives on the parent chats-layout route; do not redeclare it // here. @@ -54,9 +55,7 @@ function ChatRuntime({ chatId, chat }: { chatId: string; chat: Chat }) { // Keep latest `chat` accessible to the stable adapter closure below without // recreating the adapter (and the assistant-ui runtime) on every store dispatch. const chatRef = useRef(chat); - useEffect(() => { - chatRef.current = chat; - }); + chatRef.current = chat; const initialMessages = useMemo( () => toThreadMessages(chat.seedMessages), @@ -74,19 +73,12 @@ function ChatRuntime({ chatId, chat }: { chatId: string; chat: Chat }) { const runtime = useLocalRuntime(adapter, { initialMessages }); - // Autorespond: chats arriving here from /chats/new carry the user's first - // message in seedMessages with pendingResponse=true. Trigger one startRun - // once per mount; the ref dedupes within a StrictMode mount cycle (state - // updates from consumePendingResponse aren't visible to the re-fired effect - // closure), and the store flag dedupes across mounts (e.g. navigating away - // and back to the same chat). - const didStartRef = useRef(false); - useEffect(() => { - if (!chat.pendingResponse || didStartRef.current) return; - didStartRef.current = true; - consumePendingResponse(chatId); - runtime.thread.startRun({ parentId: null }); - }, [chat.pendingResponse, chatId, consumePendingResponse, runtime]); + usePendingChatAutoresponse({ + chatId, + pendingResponse: chat.pendingResponse, + consumePendingResponse, + startRun: () => runtime.thread.startRun({ parentId: null }), + }); return ( diff --git a/apps/fabro-web/app/routes/insights-editor.tsx b/apps/fabro-web/app/routes/insights-editor.tsx index 97a19b2df..543dff517 100644 --- a/apps/fabro-web/app/routes/insights-editor.tsx +++ b/apps/fabro-web/app/routes/insights-editor.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useCallback } from "react"; +import { useState, useRef, useCallback } from "react"; import { useLocation } from "react-router"; import { Dialog, @@ -16,84 +16,14 @@ import { PencilIcon, } from "@heroicons/react/24/outline"; import { formatBytes } from "../lib/format"; - -// ── Types ── - -interface QueryResult { - columns: string[]; - rows: Array>; - elapsed: number; - rowsRead: number; - bytesRead: number; - rowsReturned: number; -} +import { useResizeObserver } from "../hooks/effects"; +import { + type QueryResult, + useInsightsQueryRunner, +} from "../hooks/use-insights-query-runner"; type ResultView = "chart" | "table"; -// ── Mock data ── - -function generateMockResult(sql: string): QueryResult { - const lowerSql = sql.toLowerCase(); - - if (lowerSql.includes("workflow_name") && lowerSql.includes("avg")) { - return { - columns: ["workflow_name", "avg_duration", "run_count"], - rows: [ - { workflow_name: "Expand Product", avg_duration: 342.5, run_count: 48 }, - { workflow_name: "Implement Feature", avg_duration: 287.3, run_count: 156 }, - { workflow_name: "Security Scan", avg_duration: 198.1, run_count: 312 }, - { workflow_name: "Fix Build", avg_duration: 145.7, run_count: 482 }, - { workflow_name: "Sync Drift", avg_duration: 89.2, run_count: 94 }, - { workflow_name: "Dependency Audit", avg_duration: 67.4, run_count: 201 }, - ], - elapsed: 0.531, - rowsRead: 5182366, - bytesRead: 357780000, - rowsReturned: 6, - }; - } - - if (lowerSql.includes("failure_rate") || lowerSql.includes("failed")) { - return { - columns: ["day", "failures", "total", "failure_rate"], - rows: Array.from({ length: 14 }, (_, i) => { - const d = new Date(); - d.setDate(d.getDate() - i); - const total = 80 + Math.floor(Math.random() * 60); - const failures = Math.floor(Math.random() * 15); - return { - day: d.toISOString().slice(0, 10), - failures, - total, - failure_rate: Math.round((1000 * failures) / total) / 10, - }; - }), - elapsed: 0.287, - rowsRead: 2841092, - bytesRead: 198400000, - rowsReturned: 14, - }; - } - - return { - columns: ["repo", "runs", "total_additions", "total_deletions"], - rows: [ - { repo: "fabro-engine", runs: 482, total_additions: 28450, total_deletions: 12300 }, - { repo: "fabro-web", runs: 356, total_additions: 19200, total_deletions: 8900 }, - { repo: "fabro-cli", runs: 198, total_additions: 8700, total_deletions: 4200 }, - { repo: "fabro-docs", runs: 145, total_additions: 12100, total_deletions: 3400 }, - { repo: "fabro-sdk", runs: 89, total_additions: 5600, total_deletions: 2100 }, - { repo: "fabro-infra", runs: 67, total_additions: 3200, total_deletions: 1800 }, - { repo: "fabro-actions", runs: 42, total_additions: 2100, total_deletions: 980 }, - { repo: "fabro-proto", runs: 28, total_additions: 1400, total_deletions: 650 }, - ], - elapsed: 0.148, - rowsRead: 1204588, - bytesRead: 89200000, - rowsReturned: 8, - }; -} - // ── Formatting helpers ── function formatNumber(n: number): string { @@ -113,20 +43,12 @@ function BarChart({ result }: { result: QueryResult }) { const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); - useEffect(() => { - const el = containerRef.current; - if (!el) return; - - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (entry) { - setContainerWidth(entry.contentRect.width); - } - }); - // react-doctor-disable-next-line react-doctor/no-initialize-state -- ResizeObserver is the first reliable source for this rendered container's width. - observer.observe(el); - return () => observer.disconnect(); - }, []); + useResizeObserver(containerRef, (entries) => { + const entry = entries[0]; + if (entry) { + setContainerWidth(entry.contentRect.width); + } + }); const labelCol = result.columns[0]; const valueCols = result.columns.slice(1).filter((col) => { @@ -382,46 +304,13 @@ export default function InsightsEditor() { const initialQueryName = navState?.name ?? "Run duration by workflow"; const [sql, setSql] = useState(() => initialSql); - const [result, setResult] = useState(() => - generateMockResult(initialSql), - ); + const { result, isRunning, runQuery } = useInsightsQueryRunner(initialSql); const [resultView, setResultView] = useState("chart"); - const [isRunning, setIsRunning] = useState(false); const [queryName, setQueryName] = useState(() => initialQueryName); const [isEditingName, setIsEditingName] = useState(false); const nameInputRef = useRef(null); const [showAiDialog, setShowAiDialog] = useState(false); const [aiPrompt, setAiPrompt] = useState(""); - const runRequestIdRef = useRef(0); - const runTimeoutRef = useRef | null>(null); - - const runQuery = useCallback(() => { - const requestId = runRequestIdRef.current + 1; - runRequestIdRef.current = requestId; - if (runTimeoutRef.current !== null) { - clearTimeout(runTimeoutRef.current); - } - setIsRunning(true); - const delay = 200 + Math.random() * 400; - runTimeoutRef.current = setTimeout(() => { - if (runRequestIdRef.current !== requestId) return; - runTimeoutRef.current = null; - setResult(generateMockResult(sql)); - setIsRunning(false); - }, delay); - }, [sql]); - - useEffect(() => { - const runRequestIds = runRequestIdRef; - const runTimeouts = runTimeoutRef; - return () => { - runRequestIds.current += 1; - if (runTimeouts.current !== null) { - clearTimeout(runTimeouts.current); - runTimeouts.current = null; - } - }; - }, []); return (
@@ -490,7 +379,7 @@ export default function InsightsEditor() { {/* Run */}
- + runQuery(sql)} />
{/* ── Results bar + content ── */} diff --git a/apps/fabro-web/app/routes/redirect-home.tsx b/apps/fabro-web/app/routes/redirect-home.tsx index a38e48044..2ab6695eb 100644 --- a/apps/fabro-web/app/routes/redirect-home.tsx +++ b/apps/fabro-web/app/routes/redirect-home.tsx @@ -1,22 +1,17 @@ -import { useEffect } from "react"; -import { useNavigate } from "react-router"; +import { Navigate } from "react-router"; import { ApiError } from "../lib/api-client"; import { useAuthMe } from "../lib/queries"; export default function RedirectHome() { - const navigate = useNavigate(); const { data, error } = useAuthMe(); - useEffect(() => { - if (data) { - navigate("/runs", { replace: true }); - return; - } + if (data) { + return ; + } - if (error instanceof ApiError && error.status === 401) { - navigate("/login", { replace: true }); - } - }, [data, error, navigate]); + if (error instanceof ApiError && error.status === 401) { + return ; + } return null; } diff --git a/apps/fabro-web/app/routes/run-artifacts.tsx b/apps/fabro-web/app/routes/run-artifacts.tsx index 40d4cf27f..c44b6c633 100644 --- a/apps/fabro-web/app/routes/run-artifacts.tsx +++ b/apps/fabro-web/app/routes/run-artifacts.tsx @@ -1,12 +1,12 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo } from "react"; import { useParams } from "react-router"; import { ArrowDownTrayIcon, PaperClipIcon } from "@heroicons/react/24/outline"; import type { RunArtifactEntry } from "@qltysh/fabro-api-client"; import { EmptyState, ErrorState, LoadingState } from "../components/state"; import { StageSidebar } from "../components/stage-sidebar"; -import { formatBytes } from "../lib/format"; import { stageArtifactDownloadUrl } from "../lib/api-client"; +import { formatBytes } from "../lib/format"; import { useRunArtifacts, useRunStages } from "../lib/queries"; import { formatStageLabel, mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; @@ -178,22 +178,12 @@ function StageGroupCard({ runId, group }: { runId: string; group: StageGroup }) } function ArtifactRow({ runId, entry }: { runId: string; entry: RunArtifactEntry }) { - const [href, setHref] = useState("#"); - - useEffect(() => { - let active = true; - void stageArtifactDownloadUrl( - runId, - entry.stage_id, - entry.relative_path, - entry.retry, - ).then((url) => { - if (active) setHref(url); - }); - return () => { - active = false; - }; - }, [entry.relative_path, entry.retry, entry.stage_id, runId]); + const href = stageArtifactDownloadUrl( + runId, + entry.stage_id, + entry.relative_path, + entry.retry, + ); return (
  • diff --git a/apps/fabro-web/app/routes/run-children.tsx b/apps/fabro-web/app/routes/run-children.tsx index f4f5ab826..7a85e95f4 100644 --- a/apps/fabro-web/app/routes/run-children.tsx +++ b/apps/fabro-web/app/routes/run-children.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useParams, useSearchParams } from "react-router"; +import { useCallback, useMemo } from "react"; +import { Navigate, useParams, useSearchParams } from "react-router"; import { ArrowPathIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import type { ListRunsSortEnum } from "@qltysh/fabro-api-client"; @@ -24,6 +24,8 @@ import { SECONDARY_BUTTON_CLASS } from "../components/ui"; import { ApiError } from "../lib/api-client"; import { formatRelativeTime } from "../lib/format"; import { useRun, useRunsPage } from "../lib/queries"; +import { useTickingNow } from "../lib/time"; +import { useDataUpdatedAt } from "../hooks/use-data-updated-at"; export const handle = { wide: true, hideSteerBar: true }; @@ -36,6 +38,8 @@ export default function RunChildren() { () => resolveChildRunsListSearchParams(urlSearchParams), [urlSearchParams], ); + const hydratedSearch = + searchParams === urlSearchParams ? null : `?${searchParams.toString()}`; const query = searchParams.get("search") ?? ""; const sort = parseSort(searchParams.get("sort")); @@ -86,14 +90,6 @@ export default function RunChildren() { [updatePreferences], ); - const hydratedFromStorage = useRef(false); - useEffect(() => { - if (hydratedFromStorage.current) return; - hydratedFromStorage.current = true; - if (searchParams === urlSearchParams) return; - setSearchParams(searchParams, { replace: true }); - }, [searchParams, urlSearchParams, setSearchParams]); - const childRunsQuery = useRunsPage( { parentId: id, @@ -106,121 +102,122 @@ export default function RunChildren() { id != null, ); - const lastFetchedAtRef = useRef(null); - const [now, setNow] = useState(() => Date.now()); - - useEffect(() => { - if (childRunsQuery.data) { - lastFetchedAtRef.current = Date.now(); - setNow(Date.now()); - } - }, [childRunsQuery.data]); - - useEffect(() => { - const interval = window.setInterval(() => setNow(Date.now()), 15_000); - return () => window.clearInterval(interval); - }, []); + const now = useTickingNow(true, 15_000); + const updatedAt = useDataUpdatedAt(childRunsQuery.data); const handleRefresh = useCallback(() => { void childRunsQuery.mutate(); void runQuery.mutate(); }, [childRunsQuery, runQuery]); + const searchHydration = hydratedSearch + ? + : null; if (childRunsQuery.isLoading && !childRunsQuery.data) { - return ; + return ( + <> + {searchHydration} + + + ); } const apiError = childRunsQuery.error instanceof ApiError ? childRunsQuery.error : null; if (apiError && !childRunsQuery.data) { return ( - + <> + {searchHydration} + + ); } - const updatedAt = lastFetchedAtRef.current; const lowerQuery = query.toLowerCase(); return ( -
    -
    -
    - - setQuery(e.target.value)} - className="w-full rounded-md border border-line bg-panel/80 py-2 pl-9 pr-3 text-sm text-fg-2 placeholder-fg-muted outline-none transition-colors focus:border-focus focus:ring-0" - /> -
    - -
    - {updatedAt != null ? ( - - Updated{" "} - {formatRelativeTime(new Date(updatedAt).toISOString(), now)} - - ) : null} - -
    -
    +
    - - Learn about child runs - - } - /> - } - sort={sort} - direction={direction} - page={page} - pageSize={pageSize} - hiddenColumns={hiddenColumns} - onSortClick={handleSortClick} - onPageChange={setPage} - onPageSizeChange={setPageSize} - query={lowerQuery} - repoFilter="all" - workflowFilter="all" - createdCutoffMs={null} - /> -
  • +
    + {updatedAt != null ? ( + + Updated{" "} + {formatRelativeTime(new Date(updatedAt).toISOString(), now)} + + ) : null} + +
    + + + + Learn about child runs + + } + /> + } + sort={sort} + direction={direction} + page={page} + pageSize={pageSize} + hiddenColumns={hiddenColumns} + onSortClick={handleSortClick} + onPageChange={setPage} + onPageSizeChange={setPageSize} + query={lowerQuery} + repoFilter="all" + workflowFilter="all" + createdCutoffMs={null} + /> + + ); } diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index 3f3b75390..b97da4026 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -1,4 +1,5 @@ import { + useCallback, useRef, useState, type CSSProperties, @@ -27,6 +28,7 @@ import { usePreviewRun, useRetryRun, useUnarchiveRun, + type LifecycleMutationResult, } from "../lib/mutations"; import { useRunEvents } from "../lib/run-events"; import { useRunToasts } from "../hooks/use-run-toasts"; @@ -36,6 +38,7 @@ import { canRetry, deleteErrorMessage, deleteRun, + type LifecycleAction, } from "../lib/run-actions"; import { type ActionGroups, @@ -47,8 +50,9 @@ import { } from "./run-detail/docked-controls"; import { RunDetailHeader } from "./run-detail/header"; import { + createLifecycleToastState, lifecycleActionVisibility, - useLifecycleToastResults, + updateLifecycleToastState, } from "./run-detail/lifecycle-toasts"; import { buildRunDetailRun, @@ -63,6 +67,8 @@ import { export const handle = { hideHeader: true }; +type LifecycleTrigger = () => Promise; + export function meta({ data }: any) { const run = data?.run; return [{ title: run ? `${run.title} — Fabro` : "Run — Fabro" }]; @@ -95,6 +101,7 @@ export default function RunDetail({ params }: { params: { id: string } }) { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deletePending, setDeletePending] = useState(false); const { push, dismiss } = useToast(); + const lifecycleToastStateRef = useRef(createLifecycleToastState()); const filesCount = runQuery.data?.diff?.files_changed ?? null; const childrenCount = runQuery.data?.children_count ?? null; const hasSandbox = runHasSandbox(runStateQuery.data); @@ -110,17 +117,27 @@ export default function RunDetail({ params }: { params: { id: string } }) { useRunEvents(params.id); useRunToasts(params.id); - useLifecycleToastResults( - { - cancel: cancelMutation.data, - approve: approveMutation.data, - deny: denyMutation.data, - archive: archiveMutation.data, - unarchive: unarchiveMutation.data, - retry: retryMutation.data, + const handleLifecycleMutationResult = useCallback( + ( + intent: LifecycleAction, + result: LifecycleMutationResult | undefined, + ) => { + updateLifecycleToastState( + intent, + result, + lifecycleToastStateRef, + { push, dismiss }, + intent === "retry" ? navigate : undefined, + ); }, - { push, dismiss }, - navigate, + [dismiss, navigate, push], + ); + const triggerLifecycleAction = useCallback( + async (intent: LifecycleAction, trigger: LifecycleTrigger) => { + const result = await trigger(); + handleLifecycleMutationResult(intent, result); + }, + [handleLifecycleMutationResult], ); if (runQuery.isLoading && !run) { @@ -198,9 +215,9 @@ export default function RunDetail({ params }: { params: { id: string } }) { key: "interrupt", label: "Send interrupt", pendingLabel: "Interrupting…", - pending: interruptMutation.isMutating, - disabled: statusKind !== "running", - onSelect: () => void interruptMutation.trigger(), + pending: interruptMutation.isMutating, + disabled: statusKind !== "running", + onSelect: () => void interruptMutation.trigger(), }, { key: "steer", @@ -218,7 +235,7 @@ export default function RunDetail({ params }: { params: { id: string } }) { label: "Retry", pendingLabel: "Retrying…", pending: retryPending, - onSelect: () => void retryMutation.trigger(), + onSelect: () => void triggerLifecycleAction("retry", retryMutation.trigger), }] : []), ...(visibility.showArchive @@ -227,7 +244,7 @@ export default function RunDetail({ params }: { params: { id: string } }) { label: "Archive", pendingLabel: "Archiving…", pending: archivePending, - onSelect: () => void archiveMutation.trigger(), + onSelect: () => void triggerLifecycleAction("archive", archiveMutation.trigger), }] : []), ...(visibility.showUnarchive @@ -236,7 +253,7 @@ export default function RunDetail({ params }: { params: { id: string } }) { label: "Unarchive", pendingLabel: "Restoring…", pending: unarchivePending, - onSelect: () => void unarchiveMutation.trigger(), + onSelect: () => void triggerLifecycleAction("unarchive", unarchiveMutation.trigger), }] : []), ], @@ -247,7 +264,7 @@ export default function RunDetail({ params }: { params: { id: string } }) { label: "Deny", pendingLabel: "Denying…", pending: denyPending, - onSelect: () => void denyMutation.trigger(), + onSelect: () => void triggerLifecycleAction("deny", denyMutation.trigger), }] : []), ...(visibility.showPrimaryCancel @@ -256,7 +273,7 @@ export default function RunDetail({ params }: { params: { id: string } }) { label: "Cancel", pendingLabel: "Cancelling…", pending: cancelPending, - onSelect: () => void cancelMutation.trigger(), + onSelect: () => void triggerLifecycleAction("cancel", cancelMutation.trigger), }] : []), ...(visibility.showDelete @@ -292,7 +309,7 @@ export default function RunDetail({ params }: { params: { id: string } }) { approval: { visible: approvalActionVisible, pending: approvePending, - onApprove: () => void approveMutation.trigger(), + onApprove: () => void triggerLifecycleAction("approve", approveMutation.trigger), }, menu: { runId: params.id, diff --git a/apps/fabro-web/app/routes/run-detail/docked-controls.tsx b/apps/fabro-web/app/routes/run-detail/docked-controls.tsx index b9c2eec32..0a95fe73f 100644 --- a/apps/fabro-web/app/routes/run-detail/docked-controls.tsx +++ b/apps/fabro-web/app/routes/run-detail/docked-controls.tsx @@ -1,5 +1,4 @@ import { - useEffect, useState, type ReactNode, type RefObject, @@ -20,7 +19,6 @@ import { type ApiQuestion, type AskFabro, } from "@qltysh/fabro-api-client"; -import { useAskFabroLayout } from "../../lib/ask-fabro-layout"; import { classNames } from "./model"; const ASK_FABRO_UNAVAILABLE_TOOLTIPS: Record< @@ -51,16 +49,19 @@ export function RunDetailAskFabroShell({ const askDefaultModel = askFabro?.default_model ?? null; const [askOpen, setAskOpen] = useState(false); const [askWidth, setAskWidth] = useState(SIDEBAR_WIDTH); + const [resizeActive, setResizeActive] = useState(false); const sidebarWidth = askAvailable && askOpen ? askWidth : 0; - const { setSidebarWidth, isResizing } = useAskFabroLayout(); - - useEffect(() => { - setSidebarWidth(sidebarWidth); - return () => setSidebarWidth(0); - }, [sidebarWidth, setSidebarWidth]); + const isResizing = askAvailable && resizeActive; + const shellLayoutStyle = `:root { + --fabro-ask-sidebar-width: ${sidebarWidth}px; + --fabro-ask-sidebar-transition: ${ + isResizing ? "none" : "padding 300ms cubic-bezier(0.16, 1, 0.3, 1)" + }; +}`; return ( <> + {children({ askTrigger: ( )} diff --git a/apps/fabro-web/app/routes/run-detail/lifecycle-toasts.ts b/apps/fabro-web/app/routes/run-detail/lifecycle-toasts.ts index 4049630a5..6d413879e 100644 --- a/apps/fabro-web/app/routes/run-detail/lifecycle-toasts.ts +++ b/apps/fabro-web/app/routes/run-detail/lifecycle-toasts.ts @@ -1,5 +1,3 @@ -import { useEffect, useRef } from "react"; - import type { ToastInput } from "../../components/toast"; import type { LifecycleMutationResult, @@ -27,17 +25,35 @@ interface ToastApi { dismiss: (id: string) => void; } -const INITIAL_LIFECYCLE_TOAST_STATE: LifecycleToastState = { - activeArchiveToastId: null, - lastProcessed: { - cancel: null, - approve: null, - deny: null, - archive: null, - unarchive: null, - retry: null, - }, -}; +export function createLifecycleToastState(): LifecycleToastState { + return { + activeArchiveToastId: null, + lastProcessed: { + cancel: null, + approve: null, + deny: null, + archive: null, + unarchive: null, + retry: null, + }, + }; +} + +export function updateLifecycleToastState( + intent: LifecycleAction, + result: RunDetailActionResult | undefined, + stateRef: { current: LifecycleToastState }, + toastApi: ToastApi, + navigate?: (path: string) => void, +) { + stateRef.current = handleLifecycleToastResult( + intent, + result, + stateRef.current, + toastApi, + navigate, + ); +} export function lifecycleActionVisibility(status: string | null | undefined) { return { @@ -111,71 +127,3 @@ export function handleLifecycleToastResult( toastApi.push({ message: "Run restored." }); return { ...nextState, activeArchiveToastId: null }; } - -function useLifecycleToastResult( - intent: LifecycleAction, - result: RunDetailActionResult | undefined, - stateRef: { current: LifecycleToastState }, - toastApi: ToastApi, - navigate?: (path: string) => void, -) { - const { dismiss, push } = toastApi; - - useEffect(() => { - stateRef.current = handleLifecycleToastResult( - intent, - result, - stateRef.current, - { dismiss, push }, - navigate, - ); - }, [dismiss, intent, navigate, push, result, stateRef]); -} - -export function useLifecycleToastResults( - results: Record, - toastApi: ToastApi, - navigate: (path: string) => void, -) { - const lifecycleToastStateRef = useRef( - INITIAL_LIFECYCLE_TOAST_STATE, - ); - - useLifecycleToastResult( - "cancel", - results.cancel, - lifecycleToastStateRef, - toastApi, - ); - useLifecycleToastResult( - "archive", - results.archive, - lifecycleToastStateRef, - toastApi, - ); - useLifecycleToastResult( - "approve", - results.approve, - lifecycleToastStateRef, - toastApi, - ); - useLifecycleToastResult( - "deny", - results.deny, - lifecycleToastStateRef, - toastApi, - ); - useLifecycleToastResult( - "unarchive", - results.unarchive, - lifecycleToastStateRef, - toastApi, - ); - useLifecycleToastResult( - "retry", - results.retry, - lifecycleToastStateRef, - toastApi, - navigate, - ); -} diff --git a/apps/fabro-web/app/routes/run-detail/model.ts b/apps/fabro-web/app/routes/run-detail/model.ts index 87ff0a97d..7cfcba1a6 100644 --- a/apps/fabro-web/app/routes/run-detail/model.ts +++ b/apps/fabro-web/app/routes/run-detail/model.ts @@ -1,5 +1,6 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; +import { useInterval } from "../../hooks/effects"; import { isRunStatus, mapRunToRunItem, @@ -13,10 +14,7 @@ export function classNames(...classes: Array) export function useTickingNow(intervalMs: number): number { const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - const id = setInterval(() => setNow(Date.now()), intervalMs); - return () => clearInterval(id); - }, [intervalMs]); + useInterval(() => setNow(Date.now()), intervalMs); return now; } diff --git a/apps/fabro-web/app/routes/run-files.render.test.tsx b/apps/fabro-web/app/routes/run-files.render.test.tsx index 5c9a24722..457f4bd41 100644 --- a/apps/fabro-web/app/routes/run-files.render.test.tsx +++ b/apps/fabro-web/app/routes/run-files.render.test.tsx @@ -2,6 +2,7 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; import { useRef } from "react"; import TestRenderer, { act } from "react-test-renderer"; import { MemoryRouter, Route, Routes } from "react-router"; +import { toast as sonnerToast } from "sonner"; import { ToastProvider } from "../components/toast"; @@ -177,6 +178,22 @@ function renderRunFiles(initialEntry = "/runs/run_1/files") { return renderer!; } +function treeText( + node: ReturnType, +): string { + if (!node) return ""; + if (typeof node === "string") return node; + if (Array.isArray(node)) return node.map(treeText).join(""); + return (node.children ?? []).map(treeText).join(""); +} + +async function flushAsyncUpdates() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + describe("RunFiles rendering", () => { afterEach(() => { act(() => { @@ -193,6 +210,7 @@ describe("RunFiles rendering", () => { virtualizerCalls.length = 0; providerCalls.length = 0; useRunFilesCalls.length = 0; + sonnerToast.dismiss(); delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; }); @@ -306,4 +324,17 @@ describe("RunFiles rendering", () => { expect(lastCall.patch).toContain("+uncommitted"); expect(lastCall.mountId).not.toBe(firstMountId); }); + + test("refreshing from a populated diff to an empty diff shows a no-changes toast", async () => { + currentFilesPayload = makePayload(1); + const renderer = renderRunFiles("/runs/run_1/files?scope=all"); + + currentFilesPayload = makePayload(0); + await act(async () => { + renderer.root.findByProps({ "aria-label": "Refresh files" }).props.onClick(); + }); + await flushAsyncUpdates(); + + expect(treeText(renderer.toJSON())).toContain("No changes in this run."); + }); }); diff --git a/apps/fabro-web/app/routes/run-files.tsx b/apps/fabro-web/app/routes/run-files.tsx index 6e0b5e391..a2f3a1a25 100644 --- a/apps/fabro-web/app/routes/run-files.tsx +++ b/apps/fabro-web/app/routes/run-files.tsx @@ -3,7 +3,6 @@ import { memo, Suspense, useCallback, - useEffect, useMemo, useRef, useState, @@ -43,6 +42,10 @@ import { import { fileCacheKey, stringHash } from "./run-files/cache-keys"; import { buildRunCommitOptions } from "./run-files/commit-options"; import { VirtualizedDiffList } from "./run-files/virtualized-diff-list"; +import { useLocationHash, useMediaQuery } from "../hooks/effects"; +import { useFocusAfterRefreshCompletes } from "../hooks/use-focus-after-refresh"; +import { useMinimumRefreshSpinner } from "../hooks/use-minimum-refresh-spinner"; +import { useRunFileDeepLinkFocus } from "../hooks/use-run-file-deep-link"; import { ApiError, extractRequestId } from "../lib/api-client"; import { useRun, useRunCommits, useRunFiles } from "../lib/queries"; import { @@ -50,6 +53,7 @@ import { type RunFileScope, type RunFileSelection, } from "../lib/query-keys"; +import { useTickingNow } from "../lib/time"; export { extractRequestId }; @@ -78,18 +82,7 @@ export function normalizeRunFileScope(value: string | null): RunFileScope { } function useNarrowViewport(): boolean { - const [narrow, setNarrow] = useState(() => { - if (typeof window === "undefined") return false; - return window.matchMedia(`(max-width: ${MD_BREAKPOINT_PX - 1}px)`).matches; - }); - useEffect(() => { - if (typeof window === "undefined") return; - const mql = window.matchMedia(`(max-width: ${MD_BREAKPOINT_PX - 1}px)`); - const apply = () => setNarrow(mql.matches); - mql.addEventListener("change", apply); - return () => mql.removeEventListener("change", apply); - }, []); - return narrow; + return useMediaQuery(`(max-width: ${MD_BREAKPOINT_PX - 1}px)`); } function useFreshness( @@ -101,15 +94,9 @@ function useFreshness( // would show nothing. const hasLabel = !!meta && (!!meta.to_sha_committed_at || lastFetchedAt !== null); - const [, setTick] = useState(0); - useEffect(() => { - if (!hasLabel) return undefined; - const id = setInterval(() => setTick((t) => t + 1), 10_000); - return () => clearInterval(id); - }, [hasLabel]); + const now = useTickingNow(hasLabel, 10_000); if (!meta) return null; - const now = Date.now(); const captured = meta.to_sha_committed_at ? `Captured ${formatRelative(meta.to_sha_committed_at, now)}` : null; @@ -464,6 +451,9 @@ export default function RunFiles() { toSha: selectedCommit.toSha, } : runFileScopeSelection(selectedScope); + const effectiveScope = fileSelection.kind === "commit" + ? `commit:${fileSelection.toSha}` + : fileSelection.scope; const filesQuery = useRunFiles( waitingForCommitSelection ? undefined : params.id, fileSelection, @@ -472,27 +462,14 @@ export default function RunFiles() { const { push } = useToast(); const narrow = useNarrowViewport(); const runStatus = runQuery.data?.lifecycle.status.kind; - - // Preserve the last successful payload so a failed revalidation can keep - // rendering the previous files while surfacing an inline banner. - const lastGoodDataRef = useRef(null); - const lastFetchedAtRef = useRef(null); - - useEffect(() => { - if (!filesQuery.data) return; - const message = emptyTransitionToastMessage( - lastGoodDataRef.current?.data.length ?? null, - filesQuery.data.data.length, - ); - if (message) { - push({ message }); - } - lastGoodDataRef.current = filesQuery.data; - lastFetchedAtRef.current = Date.now(); - }, [push, filesQuery.data]); - - const data: PaginatedRunFileList | null = - filesQuery.data ?? lastGoodDataRef.current; + // `useRunFiles` owns server-state retention with SWR `keepPreviousData`; when + // a revalidation fails, SWR keeps the last successful payload in `data`. + const data: PaginatedRunFileList | null = filesQuery.data ?? null; + const dataFetchedAt = useMemo(() => data ? Date.now() : null, [data]); + const [refreshConfirmation, setRefreshConfirmation] = useState<{ + scope: string; + toSha: string; + } | null>(null); const isInitialLoading = (waitingForCommitSelection || filesQuery.isLoading) && !data; const isRevalidating = filesQuery.isValidating; @@ -504,12 +481,12 @@ export default function RunFiles() { // on with no data). const apiError = filesQuery.error instanceof ApiError ? filesQuery.error : null; const revalidationError = - apiError && lastGoodDataRef.current + apiError && data ? `Couldn't refresh (${apiError.status}).` : null; - const initialError = apiError && !lastGoodDataRef.current ? apiError : null; + const initialError = apiError && !data ? apiError : null; - const freshness = useFreshness(data?.meta ?? null, lastFetchedAtRef.current); + const freshness = useFreshness(data?.meta ?? null, dataFetchedAt); // Persisted desktop preference + md-breakpoint forced unified. const [persistedStyle, setPersistedStyle] = useState( @@ -528,25 +505,33 @@ export default function RunFiles() { const refreshButtonRef = useRef(null); const containerRef = useRef(null); - const lastDeepLinkToastRef = useRef(null); - - const minRefreshTimerRef = useRef(null); - const [minRefreshActive, setMinRefreshActive] = useState(false); - const clearMinRefreshTimer = useCallback(() => { - if (minRefreshTimerRef.current !== null) { - window.clearTimeout(minRefreshTimerRef.current); - minRefreshTimerRef.current = null; - } - }, []); + const { + active: minRefreshActive, + start: startMinRefresh, + } = useMinimumRefreshSpinner(MIN_REFRESH_SPIN_MS); const handleRefresh = useCallback(() => { - clearMinRefreshTimer(); - setMinRefreshActive(true); - minRefreshTimerRef.current = window.setTimeout(() => { - setMinRefreshActive(false); - minRefreshTimerRef.current = null; - }, MIN_REFRESH_SPIN_MS); - void filesQuery.mutate(); - }, [clearMinRefreshTimer, filesQuery]); + const previousFileCount = data?.data.length ?? null; + const previousToSha = data?.meta.to_sha ?? null; + startMinRefresh(); + void filesQuery.mutate() + .then((nextData) => { + if (nextData) { + const message = emptyTransitionToastMessage( + previousFileCount, + nextData.data.length, + ); + if (message) push({ message }); + } + + const nextToSha = nextData?.meta.to_sha ?? null; + setRefreshConfirmation( + previousToSha && nextToSha === previousToSha + ? { scope: effectiveScope, toSha: nextToSha } + : null, + ); + }) + .catch(() => undefined); + }, [data, effectiveScope, filesQuery, push, startMinRefresh]); const handlePickerChange = useCallback( (selection: DiffPickerValue) => { const search = new URLSearchParams(routeLocation.search); @@ -565,20 +550,12 @@ export default function RunFiles() { }, [routeLocation.hash, routeLocation.pathname, routeLocation.search, navigate], ); - useEffect(() => clearMinRefreshTimer, [clearMinRefreshTimer]); // react-doctor-disable-next-line react-doctor/no-event-handler -- The refresh spinner is driven by both SWR revalidation and the click-owned minimum timer. const showRefreshing = isRevalidating || minRefreshActive; // Return focus to the Refresh button after a refresh visibly completes so // keyboard-first users stay oriented. - const refreshingPrev = useRef(false); - useEffect(() => { - // react-doctor-disable-next-line react-doctor/no-event-handler -- Returning focus after async refresh completion is an accessibility sync effect. - if (refreshingPrev.current && !showRefreshing) { - refreshButtonRef.current?.focus({ preventScroll: true }); - } - refreshingPrev.current = showRefreshing; - }, [showRefreshing]); + useFocusAfterRefreshCompletes(showRefreshing, refreshButtonRef); const fileCount = data?.data.length ?? 0; useFileKeyboardNav(containerRef, fileCount); @@ -588,37 +565,19 @@ export default function RunFiles() { // via per-file options on `RunFileRow` — @pierre/diffs 1.1.x // exposes no imperative expand API, so click-based "expand" is not // available. - const [hashFile, setHashFile] = useState(() => { - if (typeof window === "undefined") return null; - return decodeDeepLinkFile(window.location.hash); - }); - useEffect(() => { - if (typeof window === "undefined") return; - const onHashChange = () => - setHashFile(decodeDeepLinkFile(window.location.hash)); - window.addEventListener("hashchange", onHashChange); - return () => window.removeEventListener("hashchange", onHashChange); - }, []); + const locationHash = useLocationHash(); + const hashFile = useMemo( + () => decodeDeepLinkFile(locationHash), + [locationHash], + ); - // react-doctor-disable-next-line react-doctor/no-event-handler -- Deep-link focus has to run after URL hash and file data have both rendered matching DOM rows. - useEffect(() => { - // react-doctor-disable-next-line react-doctor/no-event-handler -- Toasting missing deep links also depends on resolved file data. - const toast = resolveDeepLinkToast(hashFile, data); - if (toast) { - if (lastDeepLinkToastRef.current !== toast.key) { - push({ message: toast.message, autoDismissMs: 5000 }); - lastDeepLinkToastRef.current = toast.key; - } - return; - } - lastDeepLinkToastRef.current = null; - if (!hashFile || !data) return; - const el = document.getElementById(fileRowId(hashFile)); - if (el) { - el.scrollIntoView({ block: "start", behavior: "smooth" }); - el.focus({ preventScroll: true }); - } - }, [data, hashFile, push]); + useRunFileDeepLinkFocus({ + data, + hashFile, + rowId: fileRowId, + resolveToast: resolveDeepLinkToast, + push, + }); const handleFileSelect = useCallback((path: string) => { if (typeof window === "undefined") return; @@ -660,17 +619,12 @@ export default function RunFiles() { selectedCommit && selectedCommit.fromSha ? { kind: "commit", sha: selectedCommit.sha } : { kind: "scope", scope: showScopePicker ? selectedScope : "committed" }; - const effectiveScope = fileSelection.kind === "commit" - ? `commit:${fileSelection.toSha}` - : fileSelection.scope; - - // Refresh is disabled when the server reports the same `to_sha` it - // reported on the previous successful fetch — no new checkpoint yet. - // `lastGoodDataRef.current` is updated in a useEffect, so during render - // it still holds the previous render's data (or null on first load). - const prevToSha = lastGoodDataRef.current?.meta?.to_sha ?? null; + // Refresh is disabled only after a user-triggered refresh confirms that the + // same selection still resolves to the same `to_sha` — no new checkpoint yet. const refreshDisabled = - !!meta.to_sha && prevToSha !== null && prevToSha === meta.to_sha; + !!meta.to_sha && + refreshConfirmation?.scope === effectiveScope && + refreshConfirmation.toSha === meta.to_sha; const toolbar = ( ; @@ -117,39 +117,19 @@ export function FileTreeSidebar({ }, }); - const didSyncModelRef = useRef(false); - useEffect(() => { - if (!didSyncModelRef.current) { - didSyncModelRef.current = true; - return; - } - model.resetPaths(paths); - model.setGitStatus(gitStatus); - pendingSelectedPathRef.current = null; - const currentSelectedPath = selectedPathRef.current; - syncSelection( - model, - model.getSelectedPaths(), - currentSelectedPath && changedPathsRef.current.has(currentSelectedPath) - ? currentSelectedPath - : null, - ); - }, [gitStatus, model, paths]); - const selection = useFileTreeSelection(model); - useEffect(() => { - const pendingSelectedPath = pendingSelectedPathRef.current; - // react-doctor-disable-next-line react-doctor/no-event-handler -- This keeps Pierre's imperative tree model aligned after the tree emits a selection change. - if (pendingSelectedPath === selectedPath) { - pendingSelectedPathRef.current = null; - } - const nextSelectedPath = pendingSelectedPath ?? selectedPath; - syncSelection( - model, - selection, - nextSelectedPath && changedPaths.has(nextSelectedPath) ? nextSelectedPath : null, - ); - }, [changedPaths, model, selectedPath, selection]); + useChangedFilesTreeSync({ + changedPaths, + changedPathsRef, + gitStatus, + model, + paths, + pendingSelectedPathRef, + selectedPath, + selectedPathRef, + selection, + syncSelection, + }); const themeStyles = useMemo( () => ({ diff --git a/apps/fabro-web/app/routes/run-files/keyboard.ts b/apps/fabro-web/app/routes/run-files/keyboard.ts index 762726bd5..d1d529ffc 100644 --- a/apps/fabro-web/app/routes/run-files/keyboard.ts +++ b/apps/fabro-web/app/routes/run-files/keyboard.ts @@ -1,5 +1,6 @@ import type { RefObject } from "react"; -import { useEffect } from "react"; + +import { useDocumentEvent } from "../../hooks/effects"; export function isEditableElement(el: Element | null): boolean { if (!el) return false; @@ -23,9 +24,9 @@ export function useFileKeyboardNav( containerRef: RefObject, fileCount: number, ) { - useEffect(() => { - if (!containerRef.current) return; - const onKey = (event: KeyboardEvent) => { + useDocumentEvent( + "keydown", + (event) => { if (event.key !== "j" && event.key !== "k") return; if (event.metaKey || event.ctrlKey || event.altKey) return; if (isEditableElement(document.activeElement)) return; @@ -50,10 +51,8 @@ export function useFileKeyboardNav( const target = rows[nextIdx]; target.focus({ preventScroll: false }); target.scrollIntoView({ block: "nearest", behavior: "smooth" }); - }; - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - // fileCount drives re-attachment so rows picked up after data changes - // stay addressable without stale references. - }, [containerRef, fileCount]); + }, + undefined, + fileCount > 0, + ); } diff --git a/apps/fabro-web/app/routes/run-overview.tsx b/apps/fabro-web/app/routes/run-overview.tsx index 688f2ddcf..7abe80333 100644 --- a/apps/fabro-web/app/routes/run-overview.tsx +++ b/apps/fabro-web/app/routes/run-overview.tsx @@ -1,6 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router"; -import { graphTheme } from "../lib/graph-theme"; import { ApiError } from "../lib/api-client"; import { useRun, useRunGraph, useRunStages } from "../lib/queries"; import { FloatingTooltip } from "../components/floating-tooltip"; @@ -14,19 +13,12 @@ import { import { GraphToolbar } from "../components/graph-toolbar"; import { EmptyState, ErrorState } from "../components/state"; import { - ACTIVE_STAGE_STATES, - SUCCEEDED_STAGE_STATES, - aggregateGraphNodeStatus, mapRunStagesToSidebarStages, - type Stage, } from "../lib/stage-sidebar"; - -const HOVER_OPEN_DELAY_MS = 200; - -interface NodeHover { - stage: Stage; - rect: DOMRect; -} +import { + useAnnotatedRunGraphSvg, + type RunGraphNodeHover, +} from "../hooks/use-annotated-run-graph-svg"; export const handle = { wide: true }; @@ -64,153 +56,21 @@ export default function RunOverview() { const [pan, setPan] = useState({ x: 0, y: 0 }); const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null); const zoom = GRAPH_ZOOM_STEPS[zoomIndex]; - const [hoveredNode, setHoveredNode] = useState(null); + const [hoveredNode, setHoveredNode] = useState(null); - // Per-stage lookup keyed by latest visit's `stageId`, used when the SVG's - // imperative hover handlers need to resolve a node to its sidebar Stage. - const stageById = useMemo(() => { - const map = new Map(); - for (const stage of stages) map.set(stage.id, stage); - return map; - }, [stages]); - - // Render SVG with stage annotations - // react-doctor-disable-next-line react-doctor/no-cascading-set-state -- This effect mutates local Set/Map instances and the Graphviz SVG DOM; it does not call React state setters. - useEffect(() => { - const inner = innerRef.current; - if (!inner || !graphSvg) return; - - inner.innerHTML = graphSvg; - const svg = inner.querySelector("svg"); - if (!svg) return; - svgRef.current = svg; - - const gt = graphTheme; - const aggregated = aggregateGraphNodeStatus(stages); - const runningDotIds = new Set(); - const failedDotIds = new Set(); - const completedDotIds = new Set(); - const dotIdToStageId = new Map(); - for (const [nodeId, { displayStatus, latestStageId }] of aggregated) { - dotIdToStageId.set(nodeId, latestStageId); - if (ACTIVE_STAGE_STATES.has(displayStatus)) { - runningDotIds.add(nodeId); - } else if (displayStatus === "failed") { - failedDotIds.add(nodeId); - } else if (SUCCEEDED_STAGE_STATES.has(displayStatus)) { - completedDotIds.add(nodeId); - } - } - - const ns = "http://www.w3.org/2000/svg"; - let openTimer: ReturnType | null = null; - const clearOpenTimer = () => { - if (openTimer !== null) { - clearTimeout(openTimer); - openTimer = null; - } - }; - const listeners: Array<{ target: Element; type: string; listener: EventListener }> = []; - const addListener = (target: Element, type: string, listener: EventListener) => { - target.addEventListener(type, listener); - listeners.push({ target, type, listener }); - }; - - for (const group of svg.querySelectorAll(".node")) { - const nodeId = group.querySelector("title")?.textContent?.trim(); - if (!nodeId) continue; - - const stageId = dotIdToStageId.get(nodeId); - const stage = stageId ? stageById.get(stageId) : undefined; - if (stageId) { - (group as SVGElement).style.cursor = "pointer"; - addListener(group, "click", () => navigate(`/runs/${id}/stages/${stageId}`)); - } - if (stage) { - addListener(group, "mouseenter", () => { - clearOpenTimer(); - const target = group as SVGGElement; - openTimer = setTimeout(() => { - openTimer = null; - setHoveredNode({ stage, rect: target.getBoundingClientRect() }); - }, HOVER_OPEN_DELAY_MS); - }); - addListener(group, "mouseleave", () => { - clearOpenTimer(); - setHoveredNode(null); - }); - } - - // Color exit node based on run outcome - if (nodeId === "exit" && terminalOutcome) { - const isSuccess = terminalOutcome === "succeeded"; - const fill = isSuccess ? gt.completedFill : gt.failedFill; - const border = isSuccess ? gt.completedBorder : gt.failedBorder; - const text = isSuccess ? gt.completedText : gt.failedText; - for (const shape of group.querySelectorAll("ellipse, polygon, path")) { - shape.setAttribute("fill", fill); - shape.setAttribute("stroke", border); - } - for (const t of group.querySelectorAll("text")) { - t.setAttribute("fill", text); - } - } else if (runningDotIds.has(nodeId)) { - for (const shape of group.querySelectorAll("ellipse, polygon, path")) { - shape.setAttribute("fill", gt.runningFill); - shape.setAttribute("stroke", gt.runningBorder); - shape.setAttribute("stroke-width", "2"); - - const animFill = document.createElementNS(ns, "animate"); - animFill.setAttribute("attributeName", "fill"); - animFill.setAttribute("values", `${gt.runningFill};${gt.runningPulseFill};${gt.runningFill}`); - animFill.setAttribute("dur", "1.5s"); - animFill.setAttribute("repeatCount", "indefinite"); - shape.appendChild(animFill); - - const animStroke = document.createElementNS(ns, "animate"); - animStroke.setAttribute("attributeName", "stroke"); - animStroke.setAttribute("values", `${gt.runningBorder};${gt.runningPulseStroke};${gt.runningBorder}`); - animStroke.setAttribute("dur", "1.5s"); - animStroke.setAttribute("repeatCount", "indefinite"); - shape.appendChild(animStroke); - - const animWidth = document.createElementNS(ns, "animate"); - animWidth.setAttribute("attributeName", "stroke-width"); - animWidth.setAttribute("values", "2;3.5;2"); - animWidth.setAttribute("dur", "1.5s"); - animWidth.setAttribute("repeatCount", "indefinite"); - shape.appendChild(animWidth); - } - for (const text of group.querySelectorAll("text")) { - text.setAttribute("fill", gt.runningText); - } - } else if (failedDotIds.has(nodeId)) { - for (const shape of group.querySelectorAll("ellipse, polygon, path")) { - shape.setAttribute("fill", gt.failedFill); - shape.setAttribute("stroke", gt.failedBorder); - } - for (const text of group.querySelectorAll("text")) { - text.setAttribute("fill", gt.failedText); - } - } else if (completedDotIds.has(nodeId)) { - for (const shape of group.querySelectorAll("ellipse, polygon, path")) { - shape.setAttribute("fill", gt.completedFill); - shape.setAttribute("stroke", gt.completedBorder); - } - for (const text of group.querySelectorAll("text")) { - text.setAttribute("fill", gt.completedText); - } - } - } - - return () => { - clearOpenTimer(); - for (const { target, type, listener } of listeners) { - target.removeEventListener(type, listener); - } - setHoveredNode(null); - }; - }, [stages, stageById, graphSvg, id, navigate, terminalOutcome]); + const openStage = useCallback( + (stageId: string) => navigate(`/runs/${id}/stages/${stageId}`), + [id, navigate], + ); + useAnnotatedRunGraphSvg({ + graphSvg, + innerRef, + onHoverChange: setHoveredNode, + onStageClick: openStage, + stages, + svgRef, + terminalOutcome, + }); const onPointerDown = useCallback((e: React.PointerEvent) => { if ((e.target as HTMLElement).closest("button")) return; diff --git a/apps/fabro-web/app/routes/run-sandbox/filesystem-panel.tsx b/apps/fabro-web/app/routes/run-sandbox/filesystem-panel.tsx index 0c8bb4a96..ff20c102d 100644 --- a/apps/fabro-web/app/routes/run-sandbox/filesystem-panel.tsx +++ b/apps/fabro-web/app/routes/run-sandbox/filesystem-panel.tsx @@ -1,6 +1,5 @@ import { useCallback, - useEffect, useMemo, useRef, useState, @@ -32,6 +31,7 @@ import { EmptyState, ErrorState, LoadingState } from "../../components/state"; import { SECONDARY_BUTTON_CLASS, Tooltip } from "../../components/ui"; import { workerFactory } from "../../lib/pierre-diffs-worker"; import { stringHash } from "../run-files/cache-keys"; +import { useResetFileTreePaths } from "../../hooks/use-file-tree-model"; export const DEFAULT_DIR = "/"; @@ -371,9 +371,7 @@ function DirectoryPane({ }, }); - useEffect(() => { - model.resetPaths(treeInputs.paths); - }, [model, treeInputs.paths]); + useResetFileTreePaths(model, treeInputs.paths); const themeStyles = useMemo( () => ({ diff --git a/apps/fabro-web/app/routes/run-source.tsx b/apps/fabro-web/app/routes/run-source.tsx index 621e94de0..93306ac75 100644 --- a/apps/fabro-web/app/routes/run-source.tsx +++ b/apps/fabro-web/app/routes/run-source.tsx @@ -1,12 +1,12 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo } from "react"; import { useParams } from "react-router"; import type { BundledLanguage } from "@pierre/diffs"; import { useRunGraphSource, useRunStages } from "../lib/queries"; import { LoadingState } from "../components/state"; import { StageSidebar } from "../components/stage-sidebar"; import { CollapsibleFile } from "../components/collapsible-file"; -import { registerDotLanguage } from "../data/register-dot-language"; import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; +import { useDotLanguageReady } from "../hooks/use-dot-language-ready"; export const handle = { wide: true }; @@ -18,17 +18,7 @@ export default function RunSource() { () => mapRunStagesToSidebarStages(stagesQuery.data), [stagesQuery.data], ); - const [dotReady, setDotReady] = useState(false); - - useEffect(() => { - let cancelled = false; - registerDotLanguage().then(() => { - if (!cancelled) setDotReady(true); - }); - return () => { - cancelled = true; - }; - }, []); + const dotReady = useDotLanguageReady(); const source = sourceQuery.data; const loading = source === undefined && !sourceQuery.error; diff --git a/apps/fabro-web/app/routes/run-terminal.tsx b/apps/fabro-web/app/routes/run-terminal.tsx index 0f7d66af9..b8f9c8c32 100644 --- a/apps/fabro-web/app/routes/run-terminal.tsx +++ b/apps/fabro-web/app/routes/run-terminal.tsx @@ -1,17 +1,11 @@ -import { useEffect } from "react"; import { Toaster } from "sonner"; import TerminalView from "../components/terminal-view"; import { ToastProvider } from "../components/toast"; +import { useDocumentTitle } from "../hooks/effects"; export default function RunTerminal({ params }: { params: { id: string } }) { - useEffect(() => { - const previous = document.title; - document.title = `Terminal · ${params.id} · Fabro`; - return () => { - document.title = previous; - }; - }, [params.id]); + useDocumentTitle(`Terminal · ${params.id} · Fabro`); return ( diff --git a/apps/fabro-web/app/routes/runs.preferences.test.tsx b/apps/fabro-web/app/routes/runs.preferences.test.tsx index 648d8e35a..dd5120af8 100644 --- a/apps/fabro-web/app/routes/runs.preferences.test.tsx +++ b/apps/fabro-web/app/routes/runs.preferences.test.tsx @@ -4,6 +4,7 @@ import { createMemoryRouter, RouterProvider } from "react-router"; import type { PaginatedRunList, Run } from "@qltysh/fabro-api-client"; import { ToastProvider } from "../components/toast"; +import { CHILD_RUNS_LIST_PREFERENCES_STORAGE_KEY } from "../components/runs-list/preferences"; import { setupReactTestEnv } from "../lib/test-utils"; class MemoryStorage { @@ -73,22 +74,62 @@ const pageRuns: PaginatedRunList = { }; const queryCalls: Array<{ hook: string; args: unknown[] }> = []; +const BOARD_REFRESH_EVENTS = new Set([ + "run.submitted", + "run.start_requested", + "run.pending", + "run.approved", + "run.denied", + "run.runnable", + "run.starting", + "run.running", + "run.removing", + "run.paused", + "run.unpaused", + "run.blocked", + "run.unblocked", + "run.completed", + "run.failed", + "run.archived", + "run.unarchived", + "run.title.updated", + "interview.started", + "interview.completed", + "interview.timeout", + "interview.interrupted", + "pull_request.created", + "pull_request.linked", + "pull_request.unlinked", +]); mock.module("../lib/queries", () => ({ useAllRuns: (...args: unknown[]) => { queryCalls.push({ hook: "useAllRuns", args }); return { data: allRuns, isLoading: false }; }, + useRun: (...args: unknown[]) => { + queryCalls.push({ hook: "useRun", args }); + return { + data: run(String(args[0] ?? "run-1")), + isLoading: false, + mutate: () => Promise.resolve(undefined), + }; + }, useRunsPage: (...args: unknown[]) => { queryCalls.push({ hook: "useRunsPage", args }); - return { data: pageRuns, isLoading: false }; + return { + data: pageRuns, + isLoading: false, + isValidating: false, + mutate: () => Promise.resolve(pageRuns), + }; }, useAuthConfig: () => ({ data: { methods: ["github"] } }), useSystemInfo: () => ({ data: { server_url: "http://127.0.0.1:32276" } }), })); mock.module("../lib/board-events", () => ({ - shouldRefreshBoardForEvent: () => false, + shouldRefreshBoardForEvent: (event: string) => BOARD_REFRESH_EVENTS.has(event), useBoardEvents: () => {}, })); @@ -100,6 +141,8 @@ const { default: Runs, RUNS_PREFERENCES_STORAGE_KEY, } = await import("./runs"); +const { default: RunChildren } = await import("./run-children"); +mock.restore(); function installWindow() { class TestElement {} @@ -149,6 +192,23 @@ async function renderRuns(initialEntry: string) { return { renderer, router }; } +async function renderChildRuns(initialEntry: string) { + const router = createMemoryRouter( + [{ path: "/runs/:id/children", element: }], + { initialEntries: [initialEntry] }, + ); + let renderer!: TestRenderer.ReactTestRenderer; + await act(async () => { + renderer = TestRenderer.create( + + + , + ); + }); + mountedRenderers.push(renderer); + return { renderer, router }; +} + async function flushEffects() { await act(async () => {}); } @@ -210,8 +270,8 @@ describe("Runs workspace preference restoration", () => { // The first frame the user sees must already reflect stored prefs. // Before this was fixed, the route briefly rendered the columns view - // with includeArchived=false (default state) before a post-commit - // useEffect restored the URL, flashing the Quick Start empty state for + // with includeArchived=false (default state) before a post-commit URL + // repair restored the URL, flashing the Quick Start empty state for // users whose only runs were archived. const firstAllRuns = queryCalls.find((c) => c.hook === "useAllRuns"); const firstRunsPage = queryCalls.find((c) => c.hook === "useRunsPage"); @@ -220,6 +280,33 @@ describe("Runs workspace preference restoration", () => { expect(firstRunsPage?.args[1]).toBe(true); }); + test("child runs applies stored list prefs on the first render and hydrates the URL", async () => { + storage.setItem( + CHILD_RUNS_LIST_PREFERENCES_STORAGE_KEY, + JSON.stringify({ + version: 1, + sort: "title", + direction: "asc", + size: 50, + }), + ); + + const { router } = await renderChildRuns("/runs/parent-run/children"); + + const firstRunsPage = queryCalls.find((c) => c.hook === "useRunsPage"); + expect(firstRunsPage?.args[0]).toMatchObject({ + parentId: "parent-run", + sort: "title", + direction: "asc", + limit: 50, + offset: 0, + }); + expect(firstRunsPage?.args[1]).toBe(true); + + await flushEffects(); + expect(router.state.location.search).toBe("?sort=title&direction=asc&size=50"); + }); + test("/runs?view=columns ignores stored list view", async () => { storage.setItem( RUNS_PREFERENCES_STORAGE_KEY, diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx index 90020da36..10e40c04c 100644 --- a/apps/fabro-web/app/routes/runs.tsx +++ b/apps/fabro-web/app/routes/runs.tsx @@ -1,5 +1,5 @@ -import { useState, useCallback, useEffect, useMemo, useRef } from "react"; -import { Link } from "react-router"; +import { useState, useCallback, useMemo, useRef } from "react"; +import { Link, Navigate } from "react-router"; import { CheckIcon, ChevronDownIcon, CommandLineIcon } from "@heroicons/react/24/outline"; import { EllipsisVerticalIcon } from "@heroicons/react/20/solid"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; @@ -718,6 +718,7 @@ function RunsLandingEmpty({ export default function Runs() { const { + hydratedSearch, query, repoFilter, workflowFilter, @@ -781,14 +782,15 @@ export default function Runs() { ), ); allWorkflows.sort(); - const [columns, setColumns] = useState(initialColumns); + const [columnsState, setColumnsState] = useState(() => ({ + base: initialColumns, + columns: initialColumns, + })); + const columns = + columnsState.base === initialColumns ? columnsState.columns : initialColumns; const lowerQuery = query.toLowerCase(); useBoardEvents(); - useEffect(() => { - setColumns(initialColumns); - }, [initialColumns]); - const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), @@ -798,15 +800,16 @@ export default function Runs() { const { active, over } = event; if (!over || active.id === over.id) return; - setColumns((prev) => - prev.map((col) => { + setColumnsState({ + base: initialColumns, + columns: columns.map((col) => { const oldIndex = col.items.findIndex((item) => item.id === active.id); const newIndex = col.items.findIndex((item) => item.id === over.id); if (oldIndex === -1 || newIndex === -1) return col; return { ...col, items: arrayMove(col.items, oldIndex, newIndex) }; }), - ); - }, []); + }); + }, [columns, initialColumns]); const totalRuns = columns.reduce((sum, col) => sum + col.items.length, 0); @@ -841,77 +844,80 @@ export default function Runs() { ); return ( - -
    - + <> + {hydratedSearch ? : null} + +
    + - {view === "columns" ? ( - <> -
    - {visibleColumns.map((col) => ( -
    - -
    - ))} -
    - {isLandingReady && totalRuns === 0 ? ( - - ) : totalRuns > 0 && filteredRuns === 0 ? ( -
    - + {view === "columns" ? ( + <> +
    + {visibleColumns.map((col) => ( +
    + +
    + ))}
    - ) : null} - - ) : ( -
    - - } - sort={sort} - direction={direction} - page={page} - pageSize={pageSize} - hiddenColumns={hiddenColumns} - onSortClick={handleSortClick} - onPageChange={setPage} - onPageSizeChange={setPageSize} - query={lowerQuery} - repoFilter={repoFilter} - workflowFilter={workflowFilter} - statusFilter={statusFilter} - createdCutoffMs={createdCutoffMs} - /> -
    - )} -
    - + {isLandingReady && totalRuns === 0 ? ( + + ) : totalRuns > 0 && filteredRuns === 0 ? ( +
    + +
    + ) : null} + + ) : ( +
    + + } + sort={sort} + direction={direction} + page={page} + pageSize={pageSize} + hiddenColumns={hiddenColumns} + onSortClick={handleSortClick} + onPageChange={setPage} + onPageSizeChange={setPageSize} + query={lowerQuery} + repoFilter={repoFilter} + workflowFilter={workflowFilter} + statusFilter={statusFilter} + createdCutoffMs={createdCutoffMs} + /> +
    + )} +
    +
    + ); } diff --git a/apps/fabro-web/app/routes/runs/workspace-preferences.ts b/apps/fabro-web/app/routes/runs/workspace-preferences.ts index ee1e2aff5..bcff1217b 100644 --- a/apps/fabro-web/app/routes/runs/workspace-preferences.ts +++ b/apps/fabro-web/app/routes/runs/workspace-preferences.ts @@ -1,8 +1,6 @@ import { useCallback, - useEffect, useMemo, - useRef, } from "react"; import { useSearchParams } from "react-router"; import type { BoardColumn, ListRunsSortEnum } from "@qltysh/fabro-api-client"; @@ -32,6 +30,8 @@ export function useRunsWorkspacePreferences() { () => resolveRunsWorkspaceSearchParams(urlSearchParams), [urlSearchParams], ); + const hydratedSearch = + searchParams === urlSearchParams ? null : `?${searchParams.toString()}`; const preferences = useMemo( () => runsWorkspacePreferencesFromSearchParams(searchParams), [searchParams], @@ -103,15 +103,8 @@ export function useRunsWorkspacePreferences() { [updatePreferences], ); - const hydratedFromStorage = useRef(false); - useEffect(() => { - if (hydratedFromStorage.current) return; - hydratedFromStorage.current = true; - if (searchParams === urlSearchParams) return; - setSearchParams(searchParams, { replace: true }); - }, [searchParams, urlSearchParams, setSearchParams]); - return { + hydratedSearch, query, repoFilter, workflowFilter, diff --git a/apps/fabro-web/app/routes/settings-live-events.test.tsx b/apps/fabro-web/app/routes/settings-live-events.test.tsx index 76cbe894c..1a284cb2e 100644 --- a/apps/fabro-web/app/routes/settings-live-events.test.tsx +++ b/apps/fabro-web/app/routes/settings-live-events.test.tsx @@ -15,11 +15,15 @@ mock.module("../lib/live-events", () => ({ if (capturedOnEvent === onEvent) capturedOnEvent = null; }; }, + useLiveEventsSubscription: (onEvent: (payload: LiveEventPayload) => void) => { + capturedOnEvent = onEvent; + }, })); const { default: SettingsLiveEvents, appendLiveEvent, MAX_EVENTS } = await import( "./settings-live-events" ); +mock.restore(); const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; diff --git a/apps/fabro-web/app/routes/settings-live-events.tsx b/apps/fabro-web/app/routes/settings-live-events.tsx index 4300ed6d8..622046c82 100644 --- a/apps/fabro-web/app/routes/settings-live-events.tsx +++ b/apps/fabro-web/app/routes/settings-live-events.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Link } from "react-router"; import { @@ -18,7 +18,7 @@ import { Tooltip } from "../components/ui"; import { eventDedupeKey } from "../lib/cross-tab-sse"; import { formatAbsoluteTs } from "../lib/format"; import { - subscribeToLiveEvents, + useLiveEventsSubscription, type LiveEventPayload, } from "../lib/live-events"; @@ -49,11 +49,9 @@ export default function SettingsLiveEvents() { const [selectedCategories, setSelectedCategories] = useState([]); const [search, setSearch] = useState(""); - useEffect(() => { - return subscribeToLiveEvents((payload) => { - setEvents((prev) => appendLiveEvent(prev, payload)); - }); - }, []); + useLiveEventsSubscription((payload) => { + setEvents((prev) => appendLiveEvent(prev, payload)); + }); const filtered = useMemo(() => { const useCategoryFilter = selectedCategories.length > 0; diff --git a/apps/fabro-web/app/routes/settings-models.tsx b/apps/fabro-web/app/routes/settings-models.tsx index 936f77da0..57b7429ed 100644 --- a/apps/fabro-web/app/routes/settings-models.tsx +++ b/apps/fabro-web/app/routes/settings-models.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import type { ReactNode } from "react"; import { Link } from "react-router"; import { @@ -28,6 +28,7 @@ import { } from "../components/runs-list/sort-header"; import { Tooltip } from "../components/ui"; import { formatContextWindow, formatTokensPerSecond } from "../lib/format"; +import { useDebouncedValue } from "../hooks/effects"; export function meta() { return [{ title: "Models — Fabro" }]; @@ -608,12 +609,3 @@ function sortModels( }); return sorted; } - -function useDebouncedValue(value: T, delayMs: number): T { - const [debounced, setDebounced] = useState(value); - useEffect(() => { - const id = setTimeout(() => setDebounced(value), delayMs); - return () => clearTimeout(id); - }, [value, delayMs]); - return debounced; -} diff --git a/apps/fabro-web/app/routes/start.tsx b/apps/fabro-web/app/routes/start.tsx index 79769c6a9..fccacea53 100644 --- a/apps/fabro-web/app/routes/start.tsx +++ b/apps/fabro-web/app/routes/start.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from "react"; +import { useState, useRef } from "react"; import { Listbox, ListboxButton, @@ -50,10 +50,6 @@ export default function Start() { const [openCategory, setOpenCategory] = useState(null); const textareaRef = useRef(null); - useEffect(() => { - textareaRef.current?.focus(); - }, []); - function autoResize() { const el = textareaRef.current; if (!el) return; @@ -96,6 +92,7 @@ export default function Start() { onKeyDown={handleKeyDown} aria-label="Workflow prompt" placeholder="Describe a workflow, pipeline, or automation..." + autoFocus rows={3} className="w-full resize-none bg-transparent px-5 pt-4 pb-14 text-[15px] leading-relaxed text-fg-2 placeholder:text-fg-muted focus:outline-none" />