Introduce approved effect hooks and migrate direct useEffect calls (#425)

## Summary

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

### Plan Summary

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

## What changed and why

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

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

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

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

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

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

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

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

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


### Fabro Details

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

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

</details>

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

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

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

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

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

    start -> work -> audit

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

```

</details>

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

---------

Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
This commit is contained in:
fabro-sh-0530[bot] 2026-05-27 10:37:29 -04:00 • committed by GitHub
parent c20c6b5361
commit b196a97ac4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 2023 additions and 1395 deletions

View file

@ -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<HTMLDivElement>) => {
@ -88,7 +88,7 @@ export default function AskFabroSidebar({
event.currentTarget.releasePointerCapture(event.pointerId);
dragOrigin.current = null;
setIsDragging(false);
setIsResizing(false);
onResizeActiveChange(false);
};
return (

View file

@ -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 (
<div

View file

@ -1,15 +1,14 @@
import {
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import {
useFloatingTooltipMeasurements,
type FloatingTooltipSize,
} from "../hooks/use-floating-tooltip-measurements";
type FloatingTooltipPlacement = "top" | "bottom";
type FloatingTooltipSize = { height: number; width: number };
const VIEWPORT_MARGIN = 12;
const OFFSET = 8;
const DEFAULT_CLASS_NAME =
@ -79,10 +78,6 @@ function floatingStyle(
};
}
function viewportSize(): FloatingTooltipSize {
return { height: window.innerHeight, width: window.innerWidth };
}
export function FloatingTooltip({
rect,
placement,
@ -94,44 +89,7 @@ export function FloatingTooltip({
children: ReactNode;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ height: 0, width: 0 });
const [viewport, setViewport] = useState<FloatingTooltipSize>(() =>
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;

View file

@ -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],

View file

@ -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",

View file

@ -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<HTMLInputElement>(null);
useEffect(() => {
if (ref.current) ref.current.indeterminate = indeterminate;
}, [indeterminate]);
return (
<input
ref={ref}
ref={(input) => {
if (input) input.indeterminate = indeterminate;
}}
type="checkbox"
aria-label={ariaLabel}
checked={checked}

View file

@ -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<ConnectionStatus>("connecting");
const [error, setError] = useState<{ message: string; recoverable: boolean } | null>(null);
const [error, setError] = useState<TerminalConnectionError | null>(null);
const terminalEl = useRef<HTMLDivElement | null>(null);
const terminalRef = useRef<XtermTerminal | null>(null);
const fitRef = useRef<XtermFitAddon | null>(null);
const socketRef = useRef<WebSocket | null>(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 (
<section
className="flex h-full min-h-0 flex-col"

View file

@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { useEffect } from "react";
import TestRenderer, { act } from "react-test-renderer";
import { toast as sonnerToast, useSonner } from "sonner";
@ -18,10 +17,7 @@ function CaptureToastApi({
onReady?: (api: ReturnType<typeof useToast>) => void;
}) {
const api = useToast();
useEffect(() => {
onReady?.(api);
}, [api, onReady]);
onReady?.(api);
return null;
}

View file

@ -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<T>(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<K extends keyof WindowEventMap>(
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<K extends keyof DocumentEventMap>(
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<T extends Element>(
ref: RefObject<T | null>,
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]);
}

View file

@ -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<string, Stage>();
for (const stage of stages) stageById.set(stage.id, stage);
const gt = graphTheme;
const aggregated = aggregateGraphNodeStatus(stages);
const runningDotIds = new Set<string>();
const failedDotIds = new Set<string>();
const completedDotIds = new Set<string>();
const dotIdToStageId = new Map<string, string>();
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<typeof setTimeout> | 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]);
}

View file

@ -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<string>;
changedPathsRef: { current: ReadonlySet<string> };
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]);
}

View file

@ -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<T>(data: T | null | undefined): number | null {
return useMemo(() => data != null ? Date.now() : null, [data]);
}

View file

@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
import { registerDotLanguage } from "../data/register-dot-language";
let dotLanguageRegistration: Promise<void> | null = null;
function ensureDotLanguageRegistered(): Promise<void> {
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;
}

View file

@ -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]);
}

View file

@ -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<HTMLDivElement>(null);
const [size, setSize] = useState({ height: 0, width: 0 });
const [viewport, setViewport] = useState<FloatingTooltipSize>(() =>
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 };
}

View file

@ -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<HTMLElement | null>,
) {
const refreshingPrev = useRef(false);
useEffect(() => {
if (refreshingPrev.current && !refreshing) {
targetRef.current?.focus({ preventScroll: true });
}
refreshingPrev.current = refreshing;
}, [refreshing, targetRef]);
}

View file

@ -0,0 +1,116 @@
import { useCallback, useEffect, useRef, useState } from "react";
export interface QueryResult {
columns: string[];
rows: Array<Record<string, string | number>>;
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<QueryResult | null>(() =>
generateMockResult(initialSql),
);
const [isRunning, setIsRunning] = useState(false);
const runRequestIdRef = useRef(0);
const runTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 };
}

View file

@ -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<SetStateAction<string | null>>;
}) {
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<string | null>(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]);
}

View file

@ -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<ReturnType<typeof setTimeout> | 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 };
}

View file

@ -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]);
}

View file

@ -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<TIdentity>({
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<string | null>(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;
}

View file

@ -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<string | null>(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]);
}

View file

@ -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<string>());

View file

@ -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<SetStateAction<TerminalConnectionError | null>>;
setStatus: Dispatch<SetStateAction<ConnectionStatus>>;
terminalEl: RefObject<HTMLDivElement | null>;
}) {
const terminalRef = useRef<XtermTerminal | null>(null);
const fitRef = useRef<XtermFitAddon | null>(null);
const socketRef = useRef<WebSocket | null>(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]);
}

View file

@ -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(
<MemoryRouter initialEntries={["/install/github?error=github-app-manifest-conversion-failed"]}>
<Routes>
<Route path="/install/*" element={<InstallApp />} />
</Routes>
</MemoryRouter>,
<StrictMode>
<MemoryRouter initialEntries={["/install/github?error=github-app-manifest-conversion-failed"]}>
<Routes>
<Route path="/install/*" element={<InstallApp />} />
</Routes>
</MemoryRouter>
</StrictMode>,
);
});

View file

@ -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<string | null>(() =>
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<StepId>(
() =>
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 <Navigate to="/install/welcome" replace />;
}
if (finishState && pathname !== "/install/finishing") {
return <Navigate to="/install/finishing" replace />;
}

View file

@ -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<InstallSessionResponse, Error> = {},
) {
return useSWR<InstallSessionResponse, Error, InstallSessionKey | null>(
installSessionKey(token),
([, , currentToken]) => getInstallSession(currentToken),
{
dedupingInterval: 0,
revalidateOnFocus: false,
revalidateOnReconnect: false,
shouldRetryOnError: false,
...options,
},
);
}

View file

@ -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 (
<DemoModeProvider value={demoMode}>
<TooltipProvider>
<AskFabroLayoutProvider>
<div
className={classNames(
"isolate",
@ -251,7 +249,6 @@ export default function AppShell() {
{typeof document !== "undefined" && (
<Toaster richColors position="bottom-right" />
)}
</AskFabroLayoutProvider>
</TooltipProvider>
</DemoModeProvider>
);
@ -269,15 +266,16 @@ function ShellMain({
fullHeight: boolean;
maxWidth: string;
}) {
const { sidebarWidth, isResizing } = useAskFabroLayout();
return (
<main
className={classNames(
!isResizing &&
"transition-[padding] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]",
fullHeight && "min-h-0 flex-1",
)}
style={{ paddingRight: sidebarWidth }}
style={{
paddingRight: "var(--fabro-ask-sidebar-width, 0px)",
transition:
"var(--fabro-ask-sidebar-transition, padding 300ms cubic-bezier(0.16, 1, 0.3, 1))",
}}
>
<div
className={classNames(

View file

@ -137,10 +137,10 @@ describe("fetchAllPages", () => {
});
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",
);
});

View file

@ -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<string> {
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}`;
}

View file

@ -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 `<main>` 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<AskFabroLayout>(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 (
<AskFabroLayoutContext.Provider value={value}>
{children}
</AskFabroLayoutContext.Provider>
);
}
export function useAskFabroLayout(): AskFabroLayout {
return use(AskFabroLayoutContext);
}

View file

@ -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();

View file

@ -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));
}, []);
}

View file

@ -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();

View file

@ -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();

View file

@ -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;
}

View file

@ -36,6 +36,7 @@ export default function AskFabro() {
runId="demo"
width={width}
onWidthChange={setWidth}
onResizeActiveChange={() => {}}
/>
</div>
);

View file

@ -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 <p className="text-sm text-fg-muted">No settings found.</p>;

View file

@ -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<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement | null>(null);
const [error, setError] = useState<string | null>(null);
const [zoomIndex, setZoomIndex] = useState(DEFAULT_ZOOM_INDEX);
const [direction, setDirection] = useState<Direction>("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;

View file

@ -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 (
<AssistantRuntimeProvider runtime={runtime}>

View file

@ -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<Record<string, string | number>>;
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<HTMLDivElement>(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<QueryResult | null>(() =>
generateMockResult(initialSql),
);
const { result, isRunning, runQuery } = useInsightsQueryRunner(initialSql);
const [resultView, setResultView] = useState<ResultView>("chart");
const [isRunning, setIsRunning] = useState(false);
const [queryName, setQueryName] = useState(() => initialQueryName);
const [isEditingName, setIsEditingName] = useState(false);
const nameInputRef = useRef<HTMLInputElement>(null);
const [showAiDialog, setShowAiDialog] = useState(false);
const [aiPrompt, setAiPrompt] = useState("");
const runRequestIdRef = useRef(0);
const runTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="space-y-4">
@ -490,7 +379,7 @@ export default function InsightsEditor() {
{/* Run */}
<button
type="button"
onClick={runQuery}
onClick={() => runQuery(sql)}
disabled={isRunning || sql.trim().length === 0}
className="inline-flex items-center gap-1.5 rounded-md border border-mint/20 bg-mint/5 px-3.5 py-1.5 text-sm font-medium text-mint transition-all hover:border-mint/50 hover:bg-mint/10 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-mint/20 disabled:hover:bg-mint/5 disabled:hover:text-mint"
>
@ -506,7 +395,7 @@ export default function InsightsEditor() {
</button>
</div>
<SqlEditor value={sql} onChange={setSql} onRun={runQuery} />
<SqlEditor value={sql} onChange={setSql} onRun={() => runQuery(sql)} />
</div>
{/* ── Results bar + content ── */}

View file

@ -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 <Navigate to="/runs" replace />;
}
if (error instanceof ApiError && error.status === 401) {
navigate("/login", { replace: true });
}
}, [data, error, navigate]);
if (error instanceof ApiError && error.status === 401) {
return <Navigate to="/login" replace />;
}
return null;
}

View file

@ -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<string>("#");
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 (
<li className="flex items-center gap-4 px-4 py-2">

View file

@ -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<number | null>(null);
const [now, setNow] = useState<number>(() => 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
? <Navigate to={{ search: hydratedSearch }} replace />
: null;
if (childRunsQuery.isLoading && !childRunsQuery.data) {
return <LoadingState label="Loading child runs…" />;
return (
<>
{searchHydration}
<LoadingState label="Loading child runs…" />
</>
);
}
const apiError =
childRunsQuery.error instanceof ApiError ? childRunsQuery.error : null;
if (apiError && !childRunsQuery.data) {
return (
<ErrorState
title="Couldn't load child runs"
description={`Server returned ${apiError.status}.`}
onRetry={handleRefresh}
/>
<>
{searchHydration}
<ErrorState
title="Couldn't load child runs"
description={`Server returned ${apiError.status}.`}
onRetry={handleRefresh}
/>
</>
);
}
const updatedAt = lastFetchedAtRef.current;
const lowerQuery = query.toLowerCase();
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<div className="relative w-64">
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
<input
type="text"
name="search"
aria-label="Search child runs"
placeholder="Search child runs…"
value={query}
onChange={(e) => 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"
/>
</div>
<div className="ml-auto flex items-center gap-3">
{updatedAt != null ? (
<span className="font-mono text-xs text-fg-muted">
Updated{" "}
{formatRelativeTime(new Date(updatedAt).toISOString(), now)}
</span>
) : null}
<button
type="button"
onClick={handleRefresh}
disabled={childRunsQuery.isValidating}
aria-label={
childRunsQuery.isValidating
? "Refreshing child runs"
: "Refresh child runs"
}
title="Refresh"
className="inline-flex size-9 items-center justify-center rounded-md border border-line bg-panel/80 text-fg-3 transition-colors hover:bg-panel hover:text-fg disabled:cursor-default disabled:opacity-60 disabled:hover:bg-panel/80 disabled:hover:text-fg-3"
>
<ArrowPathIcon
className={`size-4 ${childRunsQuery.isValidating ? "animate-spin [animation-duration:450ms]" : ""}`}
aria-hidden="true"
<>
{searchHydration}
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<div className="relative w-64">
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
<input
type="text"
name="search"
aria-label="Search child runs"
placeholder="Search child runs…"
value={query}
onChange={(e) => 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"
/>
</button>
<ColumnPickerButton hidden={hiddenColumns} onChange={setHiddenColumns} />
</div>
</div>
</div>
<RunsListView
data={childRunsQuery.data ?? undefined}
isLoading={childRunsQuery.data == null && childRunsQuery.isLoading}
emptyState={
<EmptyState
title="No child runs"
description="When you launch another run with this run as its parent, it will appear here."
action={
<a
href="https://docs.fabro.sh/execution/child-runs"
target="_blank"
rel="noopener noreferrer"
className={SECONDARY_BUTTON_CLASS}
>
Learn about child runs
</a>
}
/>
}
sort={sort}
direction={direction}
page={page}
pageSize={pageSize}
hiddenColumns={hiddenColumns}
onSortClick={handleSortClick}
onPageChange={setPage}
onPageSizeChange={setPageSize}
query={lowerQuery}
repoFilter="all"
workflowFilter="all"
createdCutoffMs={null}
/>
</div>
<div className="ml-auto flex items-center gap-3">
{updatedAt != null ? (
<span className="font-mono text-xs text-fg-muted">
Updated{" "}
{formatRelativeTime(new Date(updatedAt).toISOString(), now)}
</span>
) : null}
<button
type="button"
onClick={handleRefresh}
disabled={childRunsQuery.isValidating}
aria-label={
childRunsQuery.isValidating
? "Refreshing child runs"
: "Refresh child runs"
}
title="Refresh"
className="inline-flex size-9 items-center justify-center rounded-md border border-line bg-panel/80 text-fg-3 transition-colors hover:bg-panel hover:text-fg disabled:cursor-default disabled:opacity-60 disabled:hover:bg-panel/80 disabled:hover:text-fg-3"
>
<ArrowPathIcon
className={`size-4 ${childRunsQuery.isValidating ? "animate-spin [animation-duration:450ms]" : ""}`}
aria-hidden="true"
/>
</button>
<ColumnPickerButton hidden={hiddenColumns} onChange={setHiddenColumns} />
</div>
</div>
<RunsListView
data={childRunsQuery.data ?? undefined}
isLoading={childRunsQuery.data == null && childRunsQuery.isLoading}
emptyState={
<EmptyState
title="No child runs"
description="When you launch another run with this run as its parent, it will appear here."
action={
<a
href="https://docs.fabro.sh/execution/child-runs"
target="_blank"
rel="noopener noreferrer"
className={SECONDARY_BUTTON_CLASS}
>
Learn about child runs
</a>
}
/>
}
sort={sort}
direction={direction}
page={page}
pageSize={pageSize}
hiddenColumns={hiddenColumns}
onSortClick={handleSortClick}
onPageChange={setPage}
onPageSizeChange={setPageSize}
query={lowerQuery}
repoFilter="all"
workflowFilter="all"
createdCutoffMs={null}
/>
</div>
</>
);
}

View file

@ -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<LifecycleMutationResult | undefined>;
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,

View file

@ -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 (
<>
<style>{shellLayoutStyle}</style>
{children({
askTrigger: (
<AskFabroTriggerButton
@ -82,6 +83,7 @@ export function RunDetailAskFabroShell({
defaultModel={askDefaultModel}
width={askWidth}
onWidthChange={setAskWidth}
onResizeActiveChange={setResizeActive}
/>
</div>
)}

View file

@ -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<LifecycleAction, RunDetailActionResult | undefined>,
toastApi: ToastApi,
navigate: (path: string) => void,
) {
const lifecycleToastStateRef = useRef<LifecycleToastState>(
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,
);
}

View file

@ -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<string | false | null | undefined>)
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;
}

View file

@ -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<TestRenderer.ReactTestRenderer["toJSON"]>,
): 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.");
});
});

View file

@ -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<PaginatedRunFileList | null>(null);
const lastFetchedAtRef = useRef<number | null>(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<DiffStyle>(
@ -528,25 +505,33 @@ export default function RunFiles() {
const refreshButtonRef = useRef<HTMLButtonElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const lastDeepLinkToastRef = useRef<string | null>(null);
const minRefreshTimerRef = useRef<number | null>(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<string | null>(() => {
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 = (
<Toolbar

View file

@ -1,5 +1,4 @@
import {
useEffect,
useMemo,
useRef,
type CSSProperties,
@ -17,6 +16,7 @@ import {
} from "@pierre/trees";
import pierreDark from "@pierre/theme/pierre-dark";
import type { FileDiff } from "@qltysh/fabro-api-client";
import { useChangedFilesTreeSync } from "../../hooks/use-changed-files-tree-sync";
type TreeThemeStyle = CSSProperties & Record<`--${string}`, string | number>;
@ -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<TreeThemeStyle>(
() => ({

View file

@ -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<HTMLDivElement | null>,
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,
);
}

View file

@ -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<NodeHover | null>(null);
const [hoveredNode, setHoveredNode] = useState<RunGraphNodeHover | null>(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<string, Stage>();
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<string>();
const failedDotIds = new Set<string>();
const completedDotIds = new Set<string>();
const dotIdToStageId = new Map<string, string>();
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<typeof setTimeout> | 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;

View file

@ -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<TreeThemeStyle>(
() => ({

View file

@ -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;

View file

@ -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 (
<ToastProvider>

View file

@ -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: <RunChildren /> }],
{ initialEntries: [initialEntry] },
);
let renderer!: TestRenderer.ReactTestRenderer;
await act(async () => {
renderer = TestRenderer.create(
<ToastProvider>
<RouterProvider router={router} />
</ToastProvider>,
);
});
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,

View file

@ -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 (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<div className="flex h-full min-h-0 flex-col gap-4">
<RunsToolbar
query={query}
repoFilter={repoFilter}
workflowFilter={workflowFilter}
createdFilter={createdFilter}
statusFilter={statusFilter}
includeArchived={includeArchived}
view={view}
hiddenColumns={hiddenColumns}
allRepos={allRepos}
allWorkflows={allWorkflows}
onQueryChange={setQuery}
onRepoFilterChange={setRepoFilter}
onWorkflowFilterChange={setWorkflowFilter}
onCreatedFilterChange={setCreatedFilter}
onStatusFilterChange={setStatusFilter}
onIncludeArchivedChange={setIncludeArchived}
onViewChange={setView}
onHiddenColumnsChange={setHiddenColumns}
/>
<>
{hydratedSearch ? <Navigate to={{ search: hydratedSearch }} replace /> : null}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<div className="flex h-full min-h-0 flex-col gap-4">
<RunsToolbar
query={query}
repoFilter={repoFilter}
workflowFilter={workflowFilter}
createdFilter={createdFilter}
statusFilter={statusFilter}
includeArchived={includeArchived}
view={view}
hiddenColumns={hiddenColumns}
allRepos={allRepos}
allWorkflows={allWorkflows}
onQueryChange={setQuery}
onRepoFilterChange={setRepoFilter}
onWorkflowFilterChange={setWorkflowFilter}
onCreatedFilterChange={setCreatedFilter}
onStatusFilterChange={setStatusFilter}
onIncludeArchivedChange={setIncludeArchived}
onViewChange={setView}
onHiddenColumnsChange={setHiddenColumns}
/>
{view === "columns" ? (
<>
<div className="flex min-h-0 flex-1 gap-5 overflow-x-auto pb-4">
{visibleColumns.map((col) => (
<div key={col.id} className="w-72 shrink-0">
<BoardColumnView column={col} />
</div>
))}
</div>
{isLandingReady && totalRuns === 0 ? (
<RunsLandingEmpty
hasGitHubAuth={hasGitHubAuth}
serverUrl={serverUrl}
/>
) : totalRuns > 0 && filteredRuns === 0 ? (
<div className="py-8">
<EmptyState
title="No matching runs"
description="Try clearing the search or repo filter."
/>
{view === "columns" ? (
<>
<div className="flex min-h-0 flex-1 gap-5 overflow-x-auto pb-4">
{visibleColumns.map((col) => (
<div key={col.id} className="w-72 shrink-0">
<BoardColumnView column={col} />
</div>
))}
</div>
) : null}
</>
) : (
<div className="min-h-0 flex-1 overflow-y-auto">
<RunsListView
data={listRunsPage.data}
isLoading={listRunsPage.data === undefined && listRunsPage.isLoading}
emptyState={
<RunsLandingEmpty hasGitHubAuth={hasGitHubAuth} serverUrl={serverUrl} />
}
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}
/>
</div>
)}
</div>
</DndContext>
{isLandingReady && totalRuns === 0 ? (
<RunsLandingEmpty
hasGitHubAuth={hasGitHubAuth}
serverUrl={serverUrl}
/>
) : totalRuns > 0 && filteredRuns === 0 ? (
<div className="py-8">
<EmptyState
title="No matching runs"
description="Try clearing the search or repo filter."
/>
</div>
) : null}
</>
) : (
<div className="min-h-0 flex-1 overflow-y-auto">
<RunsListView
data={listRunsPage.data}
isLoading={listRunsPage.data === undefined && listRunsPage.isLoading}
emptyState={
<RunsLandingEmpty hasGitHubAuth={hasGitHubAuth} serverUrl={serverUrl} />
}
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}
/>
</div>
)}
</div>
</DndContext>
</>
);
}

View file

@ -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,

View file

@ -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[] = [];

View file

@ -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<DebugCategory[]>([]);
const [search, setSearch] = useState("");
useEffect(() => {
return subscribeToLiveEvents((payload) => {
setEvents((prev) => appendLiveEvent(prev, payload));
});
}, []);
useLiveEventsSubscription((payload) => {
setEvents((prev) => appendLiveEvent(prev, payload));
});
const filtered = useMemo<LiveEventPayload[]>(() => {
const useCategoryFilter = selectedCategories.length > 0;

View file

@ -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<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(id);
}, [value, delayMs]);
return debounced;
}

View file

@ -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<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(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"
/>