chore: enforce exhaustive React hook dependencies (#4712)
## Summary Enables exhaustive React Hook dependency checking and resolves the existing violations across the dashboard and React hooks package. Effects and callbacks now track current values without introducing request, subscription, or render loops. ## Design Dependencies are included directly when the hook lifecycle should follow them. Timers, Remix fetchers, and realtime subscriptions use stable callbacks or latest-value refs where restarting work would change behavior. Unnecessary memoization was removed where ordinary derivation is clearer. Full lint and typechecks for the webapp and React hooks package pass.
This commit is contained in:
+1
-1
@@ -45,7 +45,7 @@
|
||||
"typescript/consistent-type-imports": "error",
|
||||
"import/no-duplicates": "error",
|
||||
"import/namespace": "off",
|
||||
"react/exhaustive-deps": "off",
|
||||
"react/exhaustive-deps": "error",
|
||||
"react/rules-of-hooks": "off",
|
||||
"guard-for-in": "error",
|
||||
"symbol-description": "error",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useAnimate } from "framer-motion";
|
||||
import { HourglassIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function AnimatedHourglassIcon({
|
||||
className,
|
||||
@@ -10,18 +10,21 @@ export function AnimatedHourglassIcon({
|
||||
delay?: number;
|
||||
}) {
|
||||
const [scope, animate] = useAnimate();
|
||||
const initialDelay = useRef(delay);
|
||||
|
||||
useEffect(() => {
|
||||
animate(
|
||||
const controls = animate(
|
||||
[
|
||||
[scope.current, { rotate: 0 }, { duration: 0.7 }],
|
||||
[scope.current, { rotate: 180 }, { duration: 0.3 }],
|
||||
[scope.current, { rotate: 180 }, { duration: 0.7 }],
|
||||
[scope.current, { rotate: 360 }, { duration: 0.3 }],
|
||||
],
|
||||
{ repeat: Infinity, delay }
|
||||
{ repeat: Infinity, delay: initialDelay.current }
|
||||
);
|
||||
}, []);
|
||||
|
||||
return () => controls.stop();
|
||||
}, [animate, scope]);
|
||||
|
||||
return <HourglassIcon ref={scope} className={className} />;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ function useAskAIState() {
|
||||
next.delete(ASK_AI_DEEP_LINK_PARAM);
|
||||
setSearchParams(next);
|
||||
}
|
||||
}, [searchParams, openAskAI]);
|
||||
}, [searchParams, setSearchParams, openAskAI]);
|
||||
|
||||
return { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI };
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export function DevPresenceProvider({ children, enabled = true }: DevPresencePro
|
||||
// Calculate isConnected and memoize the context value
|
||||
const contextValue = useMemo(() => {
|
||||
return { isConnected };
|
||||
}, [isConnected, enabled]);
|
||||
}, [isConnected]);
|
||||
|
||||
return <DevPresenceContext.Provider value={contextValue}>{children}</DevPresenceContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export function Feedback({
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [navigation.formAction, navigation.state, form.allErrors]);
|
||||
}, [navigation.formAction, navigation.state, form.allErrors, setOpen]);
|
||||
|
||||
// Handle URL param functionality
|
||||
useEffect(() => {
|
||||
@@ -83,7 +83,7 @@ export function Feedback({
|
||||
next.delete("feedbackPanel");
|
||||
setSearchParams(next);
|
||||
}
|
||||
}, [searchParams]);
|
||||
}, [searchParams, setOpen, setSearchParams]);
|
||||
|
||||
// Reset the topic to the default once the dialog closes, so reopening always starts fresh. The
|
||||
// dialog is now persistently mounted (hosted outside the popover), so without this it would keep
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import stableStringify from "json-stable-stringify";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -54,6 +54,9 @@ export function FeatureFlagsDialog({
|
||||
}: FeatureFlagsDialogProps) {
|
||||
const loadFetcher = useFetcher<LoaderData>();
|
||||
const saveFetcher = useFetcher<ActionData>();
|
||||
const loadFeatureFlags = loadFetcher.load;
|
||||
const onOpenChangeRef = useRef(onOpenChange);
|
||||
onOpenChangeRef.current = onOpenChange;
|
||||
|
||||
const [overrides, setOverrides] = useState<Record<string, unknown>>({});
|
||||
const [initialOverrides, setInitialOverrides] = useState<Record<string, unknown>>({});
|
||||
@@ -67,9 +70,9 @@ export function FeatureFlagsDialog({
|
||||
setSaveError(null);
|
||||
setOverrides({});
|
||||
setInitialOverrides({});
|
||||
loadFetcher.load(`/admin/api/v2/orgs/${orgId}/feature-flags`);
|
||||
loadFeatureFlags(`/admin/api/v2/orgs/${orgId}/feature-flags`);
|
||||
}
|
||||
}, [open, orgId]);
|
||||
}, [loadFeatureFlags, open, orgId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadFetcher.data) {
|
||||
@@ -81,7 +84,7 @@ export function FeatureFlagsDialog({
|
||||
|
||||
useEffect(() => {
|
||||
if (saveFetcher.data?.success) {
|
||||
onOpenChange(false);
|
||||
onOpenChangeRef.current(false);
|
||||
} else if (saveFetcher.data?.error) {
|
||||
setSaveError(saveFetcher.data.error);
|
||||
}
|
||||
|
||||
@@ -45,10 +45,11 @@ function DebugRunDialog({ friendlyId }: { friendlyId: string }) {
|
||||
function DebugRunContent({ friendlyId }: { friendlyId: string }) {
|
||||
const fetcher = useTypedFetcher<typeof loader>();
|
||||
const isLoading = fetcher.state === "loading";
|
||||
const load = fetcher.load;
|
||||
|
||||
useEffect(() => {
|
||||
fetcher.load(`/resources/taskruns/${friendlyId}/debug`);
|
||||
}, [friendlyId]);
|
||||
load(`/resources/taskruns/${friendlyId}/debug`);
|
||||
}, [friendlyId, load]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -65,6 +65,39 @@ export function AIQueryInput({
|
||||
}
|
||||
}, [mode, canEdit]);
|
||||
|
||||
const processStreamEvent = useCallback(
|
||||
(event: StreamEventType) => {
|
||||
switch (event.type) {
|
||||
case "thinking":
|
||||
setThinking((prev) => prev + event.content);
|
||||
break;
|
||||
case "tool_call":
|
||||
// Tool calls are handled silently — no UI text needed
|
||||
break;
|
||||
case "time_filter":
|
||||
// Apply time filter immediately when the AI sets it
|
||||
onTimeFilterChange?.(event.filter);
|
||||
break;
|
||||
case "result":
|
||||
if (event.success) {
|
||||
// Apply time filter if included in result (backup in case time_filter event was missed)
|
||||
if (event.timeFilter) {
|
||||
onTimeFilterChange?.(event.timeFilter);
|
||||
}
|
||||
onQueryGenerated(event.query);
|
||||
setPrompt("");
|
||||
setLastResult("success");
|
||||
// Keep thinking visible to show what happened
|
||||
} else {
|
||||
setError(event.error);
|
||||
setLastResult("error");
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[onQueryGenerated, onTimeFilterChange]
|
||||
);
|
||||
|
||||
const submitQuery = useCallback(
|
||||
async (queryPrompt: string, submitMode: AIQueryMode = mode) => {
|
||||
if (!queryPrompt.trim() || isLoading) return;
|
||||
@@ -158,40 +191,7 @@ export function AIQueryInput({
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[isLoading, resourcePath, mode, getCurrentQuery]
|
||||
);
|
||||
|
||||
const processStreamEvent = useCallback(
|
||||
(event: StreamEventType) => {
|
||||
switch (event.type) {
|
||||
case "thinking":
|
||||
setThinking((prev) => prev + event.content);
|
||||
break;
|
||||
case "tool_call":
|
||||
// Tool calls are handled silently — no UI text needed
|
||||
break;
|
||||
case "time_filter":
|
||||
// Apply time filter immediately when the AI sets it
|
||||
onTimeFilterChange?.(event.filter);
|
||||
break;
|
||||
case "result":
|
||||
if (event.success) {
|
||||
// Apply time filter if included in result (backup in case time_filter event was missed)
|
||||
if (event.timeFilter) {
|
||||
onTimeFilterChange?.(event.timeFilter);
|
||||
}
|
||||
onQueryGenerated(event.query);
|
||||
setPrompt("");
|
||||
setLastResult("success");
|
||||
// Keep thinking visible to show what happened
|
||||
} else {
|
||||
setError(event.error);
|
||||
setLastResult("error");
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[onQueryGenerated, onTimeFilterChange]
|
||||
[getCurrentQuery, isLoading, mode, processStreamEvent, resourcePath]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
|
||||
@@ -281,7 +281,7 @@ export function DashboardAgent({
|
||||
cancelled = true;
|
||||
stop();
|
||||
};
|
||||
}, [hasAccess, watching, actionPath, setPanelOpen, openChat]);
|
||||
}, [hasAccess, watching, actionPath, setPanelOpen, openChat, rememberToasted]);
|
||||
|
||||
// Zeroes the wake dot right away; the poll restores the truth if another chat has one. The
|
||||
// work count is not touched here: the panel derives it from the chat list.
|
||||
|
||||
@@ -476,6 +476,7 @@ export function DashboardAgentPanel({
|
||||
watchCard.requestId,
|
||||
active?.chatId,
|
||||
actionPath,
|
||||
organization.id,
|
||||
claimChatSlot,
|
||||
loadHistory,
|
||||
]);
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
vercelResourcePath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import type { loader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||
import { usePostHogTracking } from "~/hooks/usePostHog";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
|
||||
@@ -126,9 +126,15 @@ export function VercelOnboardingModal({
|
||||
const origin = searchParams.get("origin");
|
||||
const fromMarketplaceContext = origin === "marketplace";
|
||||
|
||||
const availableProjects = onboardingData?.availableProjects || [];
|
||||
const availableProjects = useMemo(
|
||||
() => onboardingData?.availableProjects ?? [],
|
||||
[onboardingData?.availableProjects]
|
||||
);
|
||||
const _hasProjectSelected = onboardingData?.hasProjectSelected ?? false;
|
||||
const customEnvironments = onboardingData?.customEnvironments || [];
|
||||
const customEnvironments = useMemo(
|
||||
() => onboardingData?.customEnvironments ?? [],
|
||||
[onboardingData?.customEnvironments]
|
||||
);
|
||||
const envVars = onboardingData?.environmentVariables || [];
|
||||
const existingVars = onboardingData?.existingVariables || {};
|
||||
const hasCustomEnvs = customEnvironments.length > 0 && hasStagingEnvironment;
|
||||
|
||||
@@ -42,60 +42,70 @@ export function NotificationPanel({
|
||||
notifications: Notification[];
|
||||
};
|
||||
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
|
||||
const dismissFetcher = useFetcher();
|
||||
const { submit: submitDismiss } = useFetcher();
|
||||
const seenIdsRef = useRef<Set<string>>(new Set());
|
||||
const seenFetcher = useFetcher();
|
||||
const { submit: submitSeen } = useFetcher();
|
||||
const clickedIdsRef = useRef<Set<string>>(new Set());
|
||||
const clickFetcher = useFetcher();
|
||||
const { submit: submitClick } = useFetcher();
|
||||
|
||||
const visibleNotifications = notifications.filter((n) => !dismissedIds.has(n.id));
|
||||
const notification = visibleNotifications[0] ?? null;
|
||||
const notificationId = notification?.id;
|
||||
|
||||
const handleDismiss = useCallback((id: string) => {
|
||||
setDismissedIds((prev) => new Set(prev).add(id));
|
||||
const handleDismiss = useCallback(
|
||||
(id: string) => {
|
||||
setDismissedIds((prev) => new Set(prev).add(id));
|
||||
|
||||
dismissFetcher.submit(
|
||||
{},
|
||||
{
|
||||
method: "POST",
|
||||
action: `/resources/platform-notifications/${id}/dismiss`,
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
submitDismiss(
|
||||
{},
|
||||
{
|
||||
method: "POST",
|
||||
action: `/resources/platform-notifications/${id}/dismiss`,
|
||||
}
|
||||
);
|
||||
},
|
||||
[submitDismiss]
|
||||
);
|
||||
|
||||
const fireClickBeacon = useCallback((id: string) => {
|
||||
if (clickedIdsRef.current.has(id)) return;
|
||||
clickedIdsRef.current.add(id);
|
||||
const fireClickBeacon = useCallback(
|
||||
(id: string) => {
|
||||
if (clickedIdsRef.current.has(id)) return;
|
||||
clickedIdsRef.current.add(id);
|
||||
|
||||
clickFetcher.submit(
|
||||
{},
|
||||
{
|
||||
method: "POST",
|
||||
action: `/resources/platform-notifications/${id}/clicked`,
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
submitClick(
|
||||
{},
|
||||
{
|
||||
method: "POST",
|
||||
action: `/resources/platform-notifications/${id}/clicked`,
|
||||
}
|
||||
);
|
||||
},
|
||||
[submitClick]
|
||||
);
|
||||
|
||||
// Fire seen beacon
|
||||
const fireSeenBeacon = useCallback((n: Notification) => {
|
||||
if (seenIdsRef.current.has(n.id)) return;
|
||||
seenIdsRef.current.add(n.id);
|
||||
const fireSeenBeacon = useCallback(
|
||||
(id: string) => {
|
||||
if (seenIdsRef.current.has(id)) return;
|
||||
seenIdsRef.current.add(id);
|
||||
|
||||
seenFetcher.submit(
|
||||
{},
|
||||
{
|
||||
method: "POST",
|
||||
action: `/resources/platform-notifications/${n.id}/seen`,
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
submitSeen(
|
||||
{},
|
||||
{
|
||||
method: "POST",
|
||||
action: `/resources/platform-notifications/${id}/seen`,
|
||||
}
|
||||
);
|
||||
},
|
||||
[submitSeen]
|
||||
);
|
||||
|
||||
// Beacon current notification on mount
|
||||
useEffect(() => {
|
||||
if (notification && !hasIncident) {
|
||||
fireSeenBeacon(notification);
|
||||
if (notificationId && !hasIncident) {
|
||||
fireSeenBeacon(notificationId);
|
||||
}
|
||||
}, [notification?.id, hasIncident]);
|
||||
}, [notificationId, hasIncident, fireSeenBeacon]);
|
||||
|
||||
if (!notification) {
|
||||
return null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { type Ref, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { type Ref, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type Layout, useContainerWidth } from "react-grid-layout";
|
||||
|
||||
/**
|
||||
@@ -33,9 +33,13 @@ export function useReorderableList<T>({
|
||||
const orderFetcher = useFetcher();
|
||||
|
||||
const [order, setOrder] = useState<string[]>(() => initialOrder ?? items.map(itemKey));
|
||||
const resetOrderRef = useRef({ initialOrder, items, itemKey });
|
||||
resetOrderRef.current = { initialOrder, items, itemKey };
|
||||
|
||||
// Sync order when organizationId changes (component may not remount)
|
||||
// Only an organization switch resets user-managed order. Keep the latest inputs in a ref so
|
||||
// ordinary item or callback identity changes don't discard a drag reorder.
|
||||
useEffect(() => {
|
||||
const { initialOrder, items, itemKey } = resetOrderRef.current;
|
||||
setOrder(initialOrder ?? items.map(itemKey));
|
||||
}, [organizationId]);
|
||||
|
||||
|
||||
@@ -288,6 +288,10 @@ export function AgentDotMatrix({
|
||||
typeof palette === "string" ? DOT_MATRIX_PALETTES[palette] : palette;
|
||||
const paletteKey = paletteObj.stops.join(",") + (paletteObj.glow ?? "");
|
||||
const playlistKey = playlist.join(",");
|
||||
const paletteObjRef = useRef(paletteObj);
|
||||
const playlistRef = useRef(playlist);
|
||||
paletteObjRef.current = paletteObj;
|
||||
playlistRef.current = playlist;
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = active;
|
||||
@@ -295,6 +299,10 @@ export function AgentDotMatrix({
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
// The serialized keys restart this animation when contents change; refs avoid restarting for
|
||||
// equivalent array/object identities while still exposing the matching current values.
|
||||
const paletteObj = paletteObjRef.current;
|
||||
const playlist = playlistRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (!canvas || !ctx) return;
|
||||
|
||||
@@ -65,7 +65,7 @@ export function AnimatedNumber({
|
||||
duration,
|
||||
ease: "easeInOut",
|
||||
});
|
||||
}, [value, duration]);
|
||||
}, [motionValue, value, duration]);
|
||||
|
||||
return <motion.span>{display}</motion.span>;
|
||||
}
|
||||
|
||||
@@ -80,12 +80,14 @@ export const CheckboxWithLabel = React.forwardRef<HTMLInputElement, CheckboxProp
|
||||
disabled,
|
||||
className,
|
||||
labelClassName: externalLabelClassName,
|
||||
onChange,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [isChecked, setIsChecked] = useState<boolean>(defaultChecked ?? false);
|
||||
const [isDisabled, setIsDisabled] = useState<boolean>(disabled ?? false);
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
const generatedId = React.useId();
|
||||
const inputId = id ?? generatedId;
|
||||
const labelId = `${inputId}-label`;
|
||||
@@ -105,9 +107,11 @@ export const CheckboxWithLabel = React.forwardRef<HTMLInputElement, CheckboxProp
|
||||
}, [disabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.onChange) {
|
||||
props.onChange(isChecked);
|
||||
}
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.(isChecked);
|
||||
}, [isChecked]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -80,20 +80,25 @@ export function DateField({
|
||||
},
|
||||
});
|
||||
|
||||
//if the passed in value changes, we should update the date
|
||||
const stateValueRef = useRef(state.value);
|
||||
stateValueRef.current = state.value;
|
||||
|
||||
// Sync only when the passed value or timezone mode changes. Depending on state.value directly
|
||||
// would reset partially edited segments back to the default after every keystroke.
|
||||
useEffect(() => {
|
||||
if (state.value === undefined && defaultValue === undefined) return;
|
||||
const stateValue = stateValueRef.current;
|
||||
if (stateValue === undefined && defaultValue === undefined) return;
|
||||
|
||||
const calendarDate = utc
|
||||
? utcDateToCalendarDate(defaultValue)
|
||||
: dateToCalendarDate(defaultValue);
|
||||
//unchanged
|
||||
if (state.value?.toDate("utc").getTime() === defaultValue?.getTime()) {
|
||||
// unchanged
|
||||
if (stateValue?.toDate(utc ? "utc" : deviceTimezone).getTime() === defaultValue?.getTime()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(calendarDate);
|
||||
}, [defaultValue]);
|
||||
}, [defaultValue, utc]);
|
||||
|
||||
const ref = useRef<null | HTMLDivElement>(null);
|
||||
const { labelProps: _labelProps, fieldProps } = useDateField(
|
||||
|
||||
@@ -243,12 +243,16 @@ const DateTimeAccurateInner = ({
|
||||
const userTimeZone = useUserTimeZone();
|
||||
// Use provided timeZone prop if available, otherwise fall back to user's preferred timezone
|
||||
const displayTimeZone = timeZone ?? userTimeZone;
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
const realPrevDate = previousDate
|
||||
? typeof previousDate === "string"
|
||||
? new Date(previousDate)
|
||||
: previousDate
|
||||
: null;
|
||||
const realDate = useMemo(() => (typeof date === "string" ? new Date(date) : date), [date]);
|
||||
const realPrevDate = useMemo(
|
||||
() =>
|
||||
previousDate
|
||||
? typeof previousDate === "string"
|
||||
? new Date(previousDate)
|
||||
: previousDate
|
||||
: null,
|
||||
[previousDate]
|
||||
);
|
||||
|
||||
// Smart formatting based on whether date changed
|
||||
const formattedDateTime = useMemo(() => {
|
||||
@@ -259,7 +263,7 @@ const DateTimeAccurateInner = ({
|
||||
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12);
|
||||
}, [realDate, displayTimeZone, locales, hour12, hideDate, previousDate]);
|
||||
}, [realDate, realPrevDate, displayTimeZone, locales, hour12, hideDate]);
|
||||
|
||||
if (!showTooltip)
|
||||
return (
|
||||
|
||||
@@ -42,15 +42,15 @@ export function DurationPicker({
|
||||
|
||||
// Sync internal state with external value changes
|
||||
useEffect(() => {
|
||||
if (controlledValue !== undefined && controlledValue !== totalSeconds) {
|
||||
const newHours = Math.floor(controlledValue / 3600);
|
||||
const newMinutes = Math.floor((controlledValue % 3600) / 60);
|
||||
const newSeconds = controlledValue % 60;
|
||||
if (controlledValue === undefined) return;
|
||||
|
||||
setHours(newHours);
|
||||
setMinutes(newMinutes);
|
||||
setSeconds(newSeconds);
|
||||
}
|
||||
const newHours = Math.floor(controlledValue / 3600);
|
||||
const newMinutes = Math.floor((controlledValue % 3600) / 60);
|
||||
const newSeconds = controlledValue % 60;
|
||||
|
||||
setHours(newHours);
|
||||
setMinutes(newMinutes);
|
||||
setSeconds(newSeconds);
|
||||
}, [controlledValue]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -39,7 +39,7 @@ function AnimationDivider({ isLoading }: LoadingBarDividerProps) {
|
||||
|
||||
exitAnimation();
|
||||
}
|
||||
}, [isPresent, isLoading]);
|
||||
}, [animate, isPresent, isLoading, safeToRemove, scope]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
|
||||
@@ -190,7 +190,7 @@ export function Select<TValue extends string | string[], TItem>({
|
||||
}
|
||||
|
||||
return matchSorter(items, searchValue, filter);
|
||||
}, [searchValue, items]);
|
||||
}, [searchValue, items, filter]);
|
||||
|
||||
const enableItemShortcuts = allowItemShortcuts && matches.length === items?.length;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import { Fragment, createContext, useCallback, useContext, useRef, useState } from "react";
|
||||
import { Fragment, createContext, useContext, useRef, useState } from "react";
|
||||
import { inverseLerp, lerp } from "~/utils/lerp";
|
||||
|
||||
interface MousePosition {
|
||||
@@ -11,26 +11,23 @@ function MousePositionProvider({ children }: { children: ReactNode }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState<MousePosition | undefined>(undefined);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!ref.current) {
|
||||
setPosition(undefined);
|
||||
return;
|
||||
}
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!ref.current) {
|
||||
setPosition(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const { top, left, width, height } = ref.current.getBoundingClientRect();
|
||||
const x = (e.clientX - left) / width;
|
||||
const y = (e.clientY - top) / height;
|
||||
const { top, left, width, height } = ref.current.getBoundingClientRect();
|
||||
const x = (e.clientX - left) / width;
|
||||
const y = (e.clientY - top) / height;
|
||||
|
||||
if (x < 0 || x > 1 || y < 0 || y > 1) {
|
||||
setPosition(undefined);
|
||||
return;
|
||||
}
|
||||
if (x < 0 || x > 1 || y < 0 || y > 1) {
|
||||
setPosition(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setPosition({ x, y });
|
||||
},
|
||||
[ref.current]
|
||||
);
|
||||
setPosition({ x, y });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { VirtualItem, Virtualizer } from "@tanstack/react-virtual";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { motion } from "framer-motion";
|
||||
import type { MutableRefObject, RefObject } from "react";
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
|
||||
import { useEffect, useMemo, useReducer, useRef } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import type { NodeState, NodesState } from "./reducer";
|
||||
import { reducer } from "./reducer";
|
||||
@@ -43,7 +43,7 @@ export function TreeView<TData>({
|
||||
if (autoFocus) {
|
||||
parentRef?.current?.focus();
|
||||
}
|
||||
}, [autoFocus, parentRef?.current]);
|
||||
}, [autoFocus, parentRef]);
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
@@ -57,21 +57,24 @@ export function TreeView<TData>({
|
||||
return map;
|
||||
}, [tree]);
|
||||
|
||||
const scrollCallback = useCallback(
|
||||
(event: Event) => {
|
||||
if (!onScroll) return;
|
||||
const target = event.target as HTMLElement;
|
||||
onScroll?.(target.scrollTop);
|
||||
},
|
||||
[onScroll]
|
||||
);
|
||||
|
||||
const onScrollRef = useRef(onScroll);
|
||||
useEffect(() => {
|
||||
//subscribe to scrollRef scroll event
|
||||
if (!scrollRef?.current || onScroll === undefined) return;
|
||||
scrollRef.current.addEventListener("scroll", scrollCallback);
|
||||
return () => scrollRef.current?.removeEventListener("scroll", scrollCallback);
|
||||
}, [scrollRef?.current]);
|
||||
onScrollRef.current = onScroll;
|
||||
}, [onScroll]);
|
||||
|
||||
const hasOnScroll = onScroll !== undefined;
|
||||
useEffect(() => {
|
||||
const scrollElement = scrollRef?.current;
|
||||
if (!scrollElement || !hasOnScroll) return;
|
||||
|
||||
const handleScroll = (event: Event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
onScrollRef.current?.(target.scrollTop);
|
||||
};
|
||||
|
||||
scrollElement.addEventListener("scroll", handleScroll);
|
||||
return () => scrollElement.removeEventListener("scroll", handleScroll);
|
||||
}, [hasOnScroll, scrollRef]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -201,11 +204,16 @@ export function useTree<TData, TFilterValue>({
|
||||
}: TreeStateHookProps<TData, TFilterValue>): UseTreeStateOutput {
|
||||
const previousNodeCount = useRef(tree.length);
|
||||
const previousSelectedId = useRef<string | undefined>(selectedId);
|
||||
const previousExternalSelectedId = useRef(selectedId);
|
||||
const onSelectedIdChangedRef = useRef(onSelectedIdChanged);
|
||||
const latestTreeRef = useRef(tree);
|
||||
latestTreeRef.current = tree;
|
||||
|
||||
const [state, dispatch] = useReducer(
|
||||
reducer,
|
||||
concreteStateFromInput({ tree, selectedId, collapsedIds, filter })
|
||||
);
|
||||
const currentSelectedId = selectedIdFromState(state.nodes);
|
||||
|
||||
// id -> index lookup so getNodeProps resolves in O(1) instead of scanning
|
||||
// the whole tree per rendered row.
|
||||
@@ -217,53 +225,56 @@ export function useTree<TData, TFilterValue>({
|
||||
return map;
|
||||
}, [tree]);
|
||||
|
||||
//sync external selectedId prop into internal state
|
||||
// Sync external selectedId changes into internal state without turning the prop into
|
||||
// a fully controlled value that immediately overrides internal keyboard selection.
|
||||
useEffect(() => {
|
||||
const internalSelectedId = selectedIdFromState(state.nodes);
|
||||
if (selectedId !== internalSelectedId) {
|
||||
if (selectedId === undefined) {
|
||||
dispatch({ type: "DESELECT_ALL_NODES" });
|
||||
} else {
|
||||
dispatch({
|
||||
type: "SELECT_NODE",
|
||||
payload: { id: selectedId, scrollToNode: false, scrollToNodeFn },
|
||||
});
|
||||
}
|
||||
if (selectedId === previousExternalSelectedId.current) return;
|
||||
previousExternalSelectedId.current = selectedId;
|
||||
|
||||
if (selectedId === undefined) {
|
||||
dispatch({ type: "DESELECT_ALL_NODES" });
|
||||
} else {
|
||||
dispatch({
|
||||
type: "SELECT_NODE",
|
||||
payload: { id: selectedId, scrollToNode: false, scrollToNodeFn: () => {} },
|
||||
});
|
||||
}
|
||||
}, [selectedId]);
|
||||
|
||||
//fire onSelectedIdChanged()
|
||||
useEffect(() => {
|
||||
const selectedId = selectedIdFromState(state.nodes);
|
||||
if (selectedId !== previousSelectedId.current) {
|
||||
previousSelectedId.current = selectedId;
|
||||
onSelectedIdChanged?.(selectedId);
|
||||
}
|
||||
}, [state.changes.selectedId]);
|
||||
onSelectedIdChangedRef.current = onSelectedIdChanged;
|
||||
}, [onSelectedIdChanged]);
|
||||
|
||||
//update tree when the number of nodes changes
|
||||
// Fire onSelectedIdChanged() only when selection changes, not when the callback is recreated.
|
||||
useEffect(() => {
|
||||
if (tree.length !== previousNodeCount.current) {
|
||||
previousNodeCount.current = tree.length;
|
||||
dispatch({ type: "UPDATE_TREE", payload: { tree } });
|
||||
if (currentSelectedId !== previousSelectedId.current) {
|
||||
previousSelectedId.current = currentSelectedId;
|
||||
onSelectedIdChangedRef.current?.(currentSelectedId);
|
||||
}
|
||||
}, [previousNodeCount.current, tree.length]);
|
||||
}, [currentSelectedId]);
|
||||
|
||||
//update the filter, if it's changed
|
||||
const previousFilter = useRef(filter);
|
||||
const treeNodeCount = tree.length;
|
||||
|
||||
// Callers may recreate the tree array; preserve reducer state unless its shape changes.
|
||||
useEffect(() => {
|
||||
//check if the value (not reference) of the filter is the same
|
||||
const previousValue = previousFilter.current
|
||||
? JSON.stringify(previousFilter.current.value)
|
||||
: undefined;
|
||||
const newValue = filter ? JSON.stringify(filter.value) : undefined;
|
||||
|
||||
previousFilter.current = filter;
|
||||
|
||||
if (previousValue !== newValue) {
|
||||
dispatch({ type: "UPDATE_FILTER", payload: { filter } });
|
||||
if (treeNodeCount !== previousNodeCount.current) {
|
||||
previousNodeCount.current = treeNodeCount;
|
||||
dispatch({ type: "UPDATE_TREE", payload: { tree: latestTreeRef.current } });
|
||||
}
|
||||
}, [filter?.value]);
|
||||
}, [treeNodeCount]);
|
||||
|
||||
const latestFilterRef = useRef(filter);
|
||||
latestFilterRef.current = filter;
|
||||
const serializedFilterValue = filter ? JSON.stringify(filter.value) : undefined;
|
||||
const previousSerializedFilterValue = useRef(serializedFilterValue);
|
||||
|
||||
// Filter behavior is keyed by value; callers may recreate the filter function every render.
|
||||
useEffect(() => {
|
||||
if (serializedFilterValue === previousSerializedFilterValue.current) return;
|
||||
|
||||
previousSerializedFilterValue.current = serializedFilterValue;
|
||||
dispatch({ type: "UPDATE_FILTER", payload: { filter: latestFilterRef.current } });
|
||||
}, [serializedFilterValue]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: state.visibleNodeIds.length,
|
||||
@@ -281,149 +292,98 @@ export function useTree<TData, TFilterValue>({
|
||||
overscan: 50,
|
||||
});
|
||||
|
||||
const scrollToNodeFn = useCallback(
|
||||
(id: string) => {
|
||||
const itemIndex = state.visibleNodeIds.findIndex((n) => n === id);
|
||||
const scrollToNodeFn = (id: string) => {
|
||||
const itemIndex = state.visibleNodeIds.findIndex((nodeId) => nodeId === id);
|
||||
|
||||
if (itemIndex !== -1) {
|
||||
virtualizer.scrollToIndex(itemIndex, { align: "auto" });
|
||||
}
|
||||
},
|
||||
[state]
|
||||
);
|
||||
if (itemIndex !== -1) {
|
||||
virtualizer.scrollToIndex(itemIndex, { align: "auto" });
|
||||
}
|
||||
};
|
||||
|
||||
const selectNode = useCallback(
|
||||
(id: string, scrollToNode = true) => {
|
||||
dispatch({ type: "SELECT_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
const selectNode = (id: string, scrollToNode = true) => {
|
||||
dispatch({ type: "SELECT_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
|
||||
};
|
||||
|
||||
const deselectNode = useCallback(
|
||||
(id: string) => {
|
||||
dispatch({ type: "DESELECT_NODE", payload: { id } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
const deselectNode = (id: string) => {
|
||||
dispatch({ type: "DESELECT_NODE", payload: { id } });
|
||||
};
|
||||
|
||||
const deselectAllNodes = useCallback(() => {
|
||||
const deselectAllNodes = () => {
|
||||
dispatch({ type: "DESELECT_ALL_NODES" });
|
||||
}, [state]);
|
||||
};
|
||||
|
||||
const toggleNodeSelection = useCallback(
|
||||
(id: string, scrollToNode = true) => {
|
||||
dispatch({ type: "TOGGLE_NODE_SELECTION", payload: { id, scrollToNode, scrollToNodeFn } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
const toggleNodeSelection = (id: string, scrollToNode = true) => {
|
||||
dispatch({ type: "TOGGLE_NODE_SELECTION", payload: { id, scrollToNode, scrollToNodeFn } });
|
||||
};
|
||||
|
||||
const expandNode = useCallback(
|
||||
(id: string, scrollToNode = true) => {
|
||||
dispatch({ type: "EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
const expandNode = (id: string, scrollToNode = true) => {
|
||||
dispatch({ type: "EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
|
||||
};
|
||||
|
||||
const collapseNode = useCallback(
|
||||
(id: string) => {
|
||||
dispatch({ type: "COLLAPSE_NODE", payload: { id } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
const collapseNode = (id: string) => {
|
||||
dispatch({ type: "COLLAPSE_NODE", payload: { id } });
|
||||
};
|
||||
|
||||
const toggleExpandNode = useCallback(
|
||||
(id: string, scrollToNode = true) => {
|
||||
dispatch({ type: "TOGGLE_EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
const toggleExpandNode = (id: string, scrollToNode = true) => {
|
||||
dispatch({ type: "TOGGLE_EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
|
||||
};
|
||||
|
||||
const selectFirstVisibleNode = useCallback(
|
||||
(scrollToNode = true) => {
|
||||
dispatch({
|
||||
type: "SELECT_FIRST_VISIBLE_NODE",
|
||||
payload: { scrollToNode, scrollToNodeFn },
|
||||
});
|
||||
},
|
||||
[tree, state]
|
||||
);
|
||||
const selectFirstVisibleNode = (scrollToNode = true) => {
|
||||
dispatch({
|
||||
type: "SELECT_FIRST_VISIBLE_NODE",
|
||||
payload: { scrollToNode, scrollToNodeFn },
|
||||
});
|
||||
};
|
||||
|
||||
const selectLastVisibleNode = useCallback(
|
||||
(scrollToNode = true) => {
|
||||
dispatch({
|
||||
type: "SELECT_LAST_VISIBLE_NODE",
|
||||
payload: { scrollToNode, scrollToNodeFn },
|
||||
});
|
||||
},
|
||||
[tree, state]
|
||||
);
|
||||
const selectLastVisibleNode = (scrollToNode = true) => {
|
||||
dispatch({
|
||||
type: "SELECT_LAST_VISIBLE_NODE",
|
||||
payload: { scrollToNode, scrollToNodeFn },
|
||||
});
|
||||
};
|
||||
|
||||
const selectNextVisibleNode = useCallback(
|
||||
(scrollToNode = true) => {
|
||||
dispatch({
|
||||
type: "SELECT_NEXT_VISIBLE_NODE",
|
||||
payload: { scrollToNode, scrollToNodeFn },
|
||||
});
|
||||
},
|
||||
[state]
|
||||
);
|
||||
const selectNextVisibleNode = (scrollToNode = true) => {
|
||||
dispatch({
|
||||
type: "SELECT_NEXT_VISIBLE_NODE",
|
||||
payload: { scrollToNode, scrollToNodeFn },
|
||||
});
|
||||
};
|
||||
|
||||
const selectPreviousVisibleNode = useCallback(
|
||||
(scrollToNode = true) => {
|
||||
dispatch({
|
||||
type: "SELECT_PREVIOUS_VISIBLE_NODE",
|
||||
payload: { scrollToNode, scrollToNodeFn },
|
||||
});
|
||||
},
|
||||
[state]
|
||||
);
|
||||
const selectPreviousVisibleNode = (scrollToNode = true) => {
|
||||
dispatch({
|
||||
type: "SELECT_PREVIOUS_VISIBLE_NODE",
|
||||
payload: { scrollToNode, scrollToNodeFn },
|
||||
});
|
||||
};
|
||||
|
||||
const selectParentNode = useCallback(
|
||||
(scrollToNode = true) => {
|
||||
dispatch({
|
||||
type: "SELECT_PARENT_NODE",
|
||||
payload: { scrollToNode, scrollToNodeFn },
|
||||
});
|
||||
},
|
||||
[state]
|
||||
);
|
||||
const selectParentNode = (scrollToNode = true) => {
|
||||
dispatch({
|
||||
type: "SELECT_PARENT_NODE",
|
||||
payload: { scrollToNode, scrollToNodeFn },
|
||||
});
|
||||
};
|
||||
|
||||
const expandAllBelowDepth = useCallback(
|
||||
(depth: number) => {
|
||||
dispatch({ type: "EXPAND_ALL_BELOW_DEPTH", payload: { depth } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
const expandAllBelowDepth = (depth: number) => {
|
||||
dispatch({ type: "EXPAND_ALL_BELOW_DEPTH", payload: { depth } });
|
||||
};
|
||||
|
||||
const collapseAllBelowDepth = useCallback(
|
||||
(depth: number) => {
|
||||
dispatch({ type: "COLLAPSE_ALL_BELOW_DEPTH", payload: { depth } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
const collapseAllBelowDepth = (depth: number) => {
|
||||
dispatch({ type: "COLLAPSE_ALL_BELOW_DEPTH", payload: { depth } });
|
||||
};
|
||||
|
||||
const expandLevel = useCallback(
|
||||
(level: number) => {
|
||||
dispatch({ type: "EXPAND_LEVEL", payload: { level } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
const expandLevel = (level: number) => {
|
||||
dispatch({ type: "EXPAND_LEVEL", payload: { level } });
|
||||
};
|
||||
|
||||
const collapseLevel = useCallback(
|
||||
(level: number) => {
|
||||
dispatch({ type: "COLLAPSE_LEVEL", payload: { level } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
const collapseLevel = (level: number) => {
|
||||
dispatch({ type: "COLLAPSE_LEVEL", payload: { level } });
|
||||
};
|
||||
|
||||
const toggleExpandLevel = useCallback(
|
||||
(level: number) => {
|
||||
dispatch({ type: "TOGGLE_EXPAND_LEVEL", payload: { level } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
const toggleExpandLevel = (level: number) => {
|
||||
dispatch({ type: "TOGGLE_EXPAND_LEVEL", payload: { level } });
|
||||
};
|
||||
|
||||
const getTreeProps = useCallback(() => {
|
||||
const getTreeProps = () => {
|
||||
return {
|
||||
role: "tree",
|
||||
"aria-multiselectable": true,
|
||||
@@ -514,26 +474,23 @@ export function useTree<TData, TFilterValue>({
|
||||
}
|
||||
},
|
||||
};
|
||||
}, [state]);
|
||||
};
|
||||
|
||||
const getNodeProps = useCallback(
|
||||
(id: string) => {
|
||||
const node = state.nodes[id];
|
||||
if (!node) return {};
|
||||
const treeItemIndex = treeIndexById.get(id) ?? -1;
|
||||
const treeItem = tree[treeItemIndex];
|
||||
return {
|
||||
"aria-expanded": node.expanded,
|
||||
"aria-level": treeItem.level + 1,
|
||||
role: "treeitem",
|
||||
tabIndex: node.selected ? -1 : undefined,
|
||||
};
|
||||
},
|
||||
[state, treeIndexById]
|
||||
);
|
||||
const getNodeProps = (id: string) => {
|
||||
const node = state.nodes[id];
|
||||
if (!node) return {};
|
||||
const treeItemIndex = treeIndexById.get(id) ?? -1;
|
||||
const treeItem = tree[treeItemIndex];
|
||||
return {
|
||||
"aria-expanded": node.expanded,
|
||||
"aria-level": treeItem.level + 1,
|
||||
role: "treeitem",
|
||||
tabIndex: node.selected ? -1 : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
selected: selectedIdFromState(state.nodes),
|
||||
selected: currentSelectedId,
|
||||
nodes: state.nodes,
|
||||
getTreeProps,
|
||||
getNodeProps,
|
||||
|
||||
@@ -377,22 +377,25 @@ export function QueryEditor({
|
||||
|
||||
// Use defaultData as initial results, then switch to fetcher data once a query is run
|
||||
const fetcherResults = fetcher.data;
|
||||
const results =
|
||||
fetcherResults ??
|
||||
(defaultData
|
||||
? {
|
||||
error: null,
|
||||
rows: defaultData.rows,
|
||||
columns: defaultData.columns,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: false,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
queryId: null,
|
||||
periodClipped: null,
|
||||
}
|
||||
: null);
|
||||
const results = useMemo(
|
||||
() =>
|
||||
fetcherResults ??
|
||||
(defaultData
|
||||
? {
|
||||
error: null,
|
||||
rows: defaultData.rows,
|
||||
columns: defaultData.columns,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: false,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
queryId: null,
|
||||
periodClipped: null,
|
||||
}
|
||||
: null),
|
||||
[defaultData, fetcherResults]
|
||||
);
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
@@ -1255,13 +1258,13 @@ function ResultsBigNumber({
|
||||
accessory?: ReactNode;
|
||||
}) {
|
||||
// Auto-select first numeric column if none selected
|
||||
const numericColumns = columns.filter((c) => isNumericColumnType(c.type));
|
||||
const firstNumericColumn = columns.find((column) => isNumericColumnType(column.type));
|
||||
|
||||
useEffect(() => {
|
||||
if (!bigNumberConfig.column && numericColumns.length > 0) {
|
||||
onBigNumberConfigChange({ ...bigNumberConfig, column: numericColumns[0].name });
|
||||
if (!bigNumberConfig.column && firstNumericColumn) {
|
||||
onBigNumberConfigChange({ ...bigNumberConfig, column: firstNumericColumn.name });
|
||||
}
|
||||
}, [columns]);
|
||||
}, [bigNumberConfig, firstNumericColumn, onBigNumberConfigChange]);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup className="overflow-hidden">
|
||||
|
||||
@@ -53,7 +53,7 @@ export function AIFilterInput() {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}
|
||||
}, [fetcher.data, navigate]);
|
||||
}, [fetcher.data, fetcher.state, navigate]);
|
||||
|
||||
const isLoading = fetcher.state === "submitting";
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export function LiveTimer({
|
||||
}, updateInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [startTime, endTime]);
|
||||
}, [startTime, endTime, updateInterval]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -56,7 +56,7 @@ export function LiveCountdown({
|
||||
}, updateInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [endTime]);
|
||||
}, [endTime, updateInterval]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -54,8 +54,10 @@ export function ReplayRunDialog({ runFriendlyId, failedRedirect }: ReplayRunDial
|
||||
|
||||
function ReplayContent({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) {
|
||||
const replayDataFetcher = useTypedFetcher<typeof loader>();
|
||||
const { load: loadReplayData } = replayDataFetcher;
|
||||
const isLoading = replayDataFetcher.state === "loading";
|
||||
const queueFetcher = useTypedFetcher<typeof queuesLoader>();
|
||||
const { load: loadQueues } = queueFetcher;
|
||||
|
||||
const [environmentIdOverride, setEnvironmentIdOverride] = useState<string | undefined>(undefined);
|
||||
|
||||
@@ -65,34 +67,35 @@ function ReplayContent({ runFriendlyId, failedRedirect }: ReplayRunDialogProps)
|
||||
searchParams.set("environmentIdOverride", environmentIdOverride);
|
||||
}
|
||||
|
||||
replayDataFetcher.load(
|
||||
`/resources/taskruns/${runFriendlyId}/replay?${searchParams.toString()}`
|
||||
);
|
||||
}, [runFriendlyId, environmentIdOverride]);
|
||||
loadReplayData(`/resources/taskruns/${runFriendlyId}/replay?${searchParams.toString()}`);
|
||||
}, [environmentIdOverride, loadReplayData, runFriendlyId]);
|
||||
|
||||
const params = useParams();
|
||||
const environmentOverrideSlug = environmentIdOverride
|
||||
? replayDataFetcher.data?.environments.find((env) => env.id === environmentIdOverride)?.slug
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (params.organizationSlug && params.projectParam && params.envParam) {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("type", "custom");
|
||||
searchParams.set("per_page", "100");
|
||||
|
||||
let envSlug = params.envParam;
|
||||
const envSlug = environmentOverrideSlug ?? params.envParam;
|
||||
|
||||
if (environmentIdOverride) {
|
||||
const environmentOverride = replayDataFetcher.data?.environments.find(
|
||||
(env) => env.id === environmentIdOverride
|
||||
);
|
||||
envSlug = environmentOverride?.slug ?? envSlug;
|
||||
}
|
||||
|
||||
queueFetcher.load(
|
||||
loadQueues(
|
||||
`/resources/orgs/${params.organizationSlug}/projects/${
|
||||
params.projectParam
|
||||
}/env/${envSlug}/queues?${searchParams.toString()}`
|
||||
);
|
||||
}
|
||||
}, [params.organizationSlug, params.projectParam, params.envParam, environmentIdOverride]);
|
||||
}, [
|
||||
environmentOverrideSlug,
|
||||
loadQueues,
|
||||
params.envParam,
|
||||
params.organizationSlug,
|
||||
params.projectParam,
|
||||
]);
|
||||
|
||||
const customQueues = useMemo(() => {
|
||||
return queueFetcher.data?.queues ?? [];
|
||||
|
||||
@@ -1012,11 +1012,14 @@ function TagsDropdown({
|
||||
from: value("from"),
|
||||
to: value("to"),
|
||||
});
|
||||
const fromTimestamp = from?.getTime();
|
||||
const toTimestamp = to?.getTime();
|
||||
|
||||
const tagValues = values("tags").filter((v) => v !== "");
|
||||
const selected = tagValues.length > 0 ? tagValues : undefined;
|
||||
|
||||
const fetcher = useFetcher<typeof tagsLoader>();
|
||||
const { load } = fetcher;
|
||||
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams();
|
||||
@@ -1026,14 +1029,14 @@ function TagsDropdown({
|
||||
if (period) {
|
||||
searchParams.set("period", period);
|
||||
}
|
||||
if (from) {
|
||||
searchParams.set("from", from.getTime().toString());
|
||||
if (fromTimestamp !== undefined) {
|
||||
searchParams.set("from", fromTimestamp.toString());
|
||||
}
|
||||
if (to) {
|
||||
searchParams.set("to", to.getTime().toString());
|
||||
if (toTimestamp !== undefined) {
|
||||
searchParams.set("to", toTimestamp.toString());
|
||||
}
|
||||
fetcher.load(`/resources/environments/${environment.id}/runs/tags?${searchParams}`);
|
||||
}, [environment.id, searchValue, period, from?.getTime(), to?.getTime()]);
|
||||
load(`/resources/environments/${environment.id}/runs/tags?${searchParams}`);
|
||||
}, [environment.id, fromTimestamp, load, period, searchValue, toTimestamp]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let items: string[] = [];
|
||||
@@ -1171,32 +1174,31 @@ function QueuesDropdown({
|
||||
250
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let items: { name: string; type: "custom" | "task"; value: string }[] = [];
|
||||
const items: { name: string; type: "custom" | "task"; value: string }[] = [];
|
||||
|
||||
for (const queueName of selected ?? []) {
|
||||
const queueItem = fetcher.data?.queues.find((q) => q.name === queueName);
|
||||
if (!queueItem) {
|
||||
if (queueName.startsWith("task/")) {
|
||||
items.push({
|
||||
name: queueName.replace("task/", ""),
|
||||
type: "task",
|
||||
value: queueName,
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
name: queueName,
|
||||
type: "custom",
|
||||
value: queueName,
|
||||
});
|
||||
}
|
||||
for (const queueName of selected ?? []) {
|
||||
const queueItem = fetcher.data?.queues.find((q) => q.name === queueName);
|
||||
if (!queueItem) {
|
||||
if (queueName.startsWith("task/")) {
|
||||
items.push({
|
||||
name: queueName.replace("task/", ""),
|
||||
type: "task",
|
||||
value: queueName,
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
name: queueName,
|
||||
type: "custom",
|
||||
value: queueName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fetcher.data === undefined) {
|
||||
return matchSorter(items, searchValue);
|
||||
}
|
||||
|
||||
let filtered: typeof items;
|
||||
if (fetcher.data === undefined) {
|
||||
filtered = matchSorter(items, searchValue);
|
||||
} else {
|
||||
items.push(
|
||||
...fetcher.data.queues.map((q) => ({
|
||||
name: q.name,
|
||||
@@ -1205,10 +1207,10 @@ function QueuesDropdown({
|
||||
}))
|
||||
);
|
||||
|
||||
return matchSorter(Array.from(new Set(items)), searchValue, {
|
||||
filtered = matchSorter(Array.from(new Set(items)), searchValue, {
|
||||
keys: ["name"],
|
||||
});
|
||||
}, [searchValue, fetcher.data]);
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectProvider value={selected ?? []} setValue={handleChange} virtualFocus={true}>
|
||||
@@ -1318,29 +1320,27 @@ function RegionsDropdown({
|
||||
|
||||
const selected = values("regions").filter((v) => v !== "");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
type RegionItem = { masterQueue: string; name: string; location?: string };
|
||||
const items: RegionItem[] = [];
|
||||
type RegionItem = { masterQueue: string; name: string; location?: string };
|
||||
const items: RegionItem[] = [];
|
||||
|
||||
for (const masterQueue of selected) {
|
||||
const known = regions.find((r) => r.masterQueue === masterQueue);
|
||||
if (!known) {
|
||||
items.push({ masterQueue, name: masterQueue });
|
||||
}
|
||||
for (const masterQueue of selected) {
|
||||
const known = regions.find((r) => r.masterQueue === masterQueue);
|
||||
if (!known) {
|
||||
items.push({ masterQueue, name: masterQueue });
|
||||
}
|
||||
}
|
||||
|
||||
for (const region of regions) {
|
||||
if (!items.some((i) => i.masterQueue === region.masterQueue)) {
|
||||
items.push({
|
||||
masterQueue: region.masterQueue,
|
||||
name: region.name,
|
||||
location: region.location,
|
||||
});
|
||||
}
|
||||
for (const region of regions) {
|
||||
if (!items.some((i) => i.masterQueue === region.masterQueue)) {
|
||||
items.push({
|
||||
masterQueue: region.masterQueue,
|
||||
name: region.name,
|
||||
location: region.location,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return matchSorter(items, searchValue, { keys: ["name", "masterQueue"] });
|
||||
}, [searchValue, regions, selected.join(",")]);
|
||||
const filtered = matchSorter(items, searchValue, { keys: ["name", "masterQueue"] });
|
||||
|
||||
return (
|
||||
<SelectProvider value={selected} setValue={handleChange} virtualFocus={true}>
|
||||
@@ -1566,33 +1566,31 @@ export function VersionsDropdown({
|
||||
250
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let items: { version: string; isCurrent: boolean }[] = [];
|
||||
const items: { version: string; isCurrent: boolean }[] = [];
|
||||
|
||||
for (const version of selected ?? []) {
|
||||
const versionItem = fetcher.data?.versions.find((v) => v.version === version);
|
||||
if (!versionItem) {
|
||||
items.push({
|
||||
version,
|
||||
isCurrent: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (fetcher.data === undefined) {
|
||||
return matchSorter(items, searchValue);
|
||||
for (const version of selected ?? []) {
|
||||
const versionItem = fetcher.data?.versions.find((v) => v.version === version);
|
||||
if (!versionItem) {
|
||||
items.push({
|
||||
version,
|
||||
isCurrent: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let filtered: typeof items;
|
||||
if (fetcher.data === undefined) {
|
||||
filtered = matchSorter(items, searchValue);
|
||||
} else {
|
||||
items.push(...fetcher.data.versions);
|
||||
|
||||
if (searchValue === "") {
|
||||
return items;
|
||||
}
|
||||
|
||||
return matchSorter(Array.from(new Set(items)), searchValue, {
|
||||
keys: ["version"],
|
||||
});
|
||||
}, [searchValue, fetcher.data]);
|
||||
filtered =
|
||||
searchValue === ""
|
||||
? items
|
||||
: matchSorter(Array.from(new Set(items)), searchValue, {
|
||||
keys: ["version"],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectProvider value={selected ?? []} setValue={handleChange} virtualFocus={true}>
|
||||
|
||||
@@ -495,7 +495,6 @@ function TimeDropdown({
|
||||
const organization = useOptionalOrganization();
|
||||
const [open, setOpen] = useState<boolean | undefined>();
|
||||
const { replace } = useSearchParams();
|
||||
const extraCleared = Object.fromEntries((clearParams ?? []).map((key) => [key, undefined]));
|
||||
const [fromValue, setFromValue] = useState(from);
|
||||
const [toValue, setToValue] = useState(to);
|
||||
|
||||
@@ -569,7 +568,7 @@ function TimeDropdown({
|
||||
onValueChange(values);
|
||||
} else {
|
||||
replace({
|
||||
...extraCleared,
|
||||
...Object.fromEntries((clearParams ?? []).map((key) => [key, undefined])),
|
||||
period: periodToApply,
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
@@ -583,7 +582,7 @@ function TimeDropdown({
|
||||
setOpen(false);
|
||||
onApply?.(values);
|
||||
},
|
||||
[maxPeriodDays, onValueChange, replace, onApply]
|
||||
[clearParams, maxPeriodDays, onValueChange, replace, onApply]
|
||||
);
|
||||
|
||||
const applySelection = useCallback(() => {
|
||||
@@ -629,7 +628,7 @@ function TimeDropdown({
|
||||
} else {
|
||||
// URL mode - navigate
|
||||
replace({
|
||||
...extraCleared,
|
||||
...Object.fromEntries((clearParams ?? []).map((key) => [key, undefined])),
|
||||
period: undefined,
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
@@ -643,6 +642,7 @@ function TimeDropdown({
|
||||
}
|
||||
}, [
|
||||
activeSection,
|
||||
clearParams,
|
||||
selectedPeriod,
|
||||
isCustomDurationValid,
|
||||
customValue,
|
||||
|
||||
@@ -78,22 +78,20 @@ export function TaskRunsList({
|
||||
}
|
||||
);
|
||||
|
||||
const onClickShowNewRuns = () => {
|
||||
const isPaginated = has("cursor") || has("direction");
|
||||
dismissNewRuns();
|
||||
if (isPaginated) {
|
||||
replace({ cursor: undefined, direction: undefined });
|
||||
return;
|
||||
}
|
||||
revalidator.revalidate();
|
||||
};
|
||||
|
||||
// Surface the banner to the top-bar button rendered by the page: keep the
|
||||
// ref's action current, mirror the count up, and clear it when this boundary
|
||||
// unmounts (e.g. the table re-suspends on a filter change).
|
||||
useEffect(() => {
|
||||
showNewRunsRef.current = onClickShowNewRuns;
|
||||
}, [onClickShowNewRuns, showNewRunsRef]);
|
||||
showNewRunsRef.current = () => {
|
||||
const isPaginated = has("cursor") || has("direction");
|
||||
dismissNewRuns();
|
||||
if (isPaginated) {
|
||||
replace({ cursor: undefined, direction: undefined });
|
||||
return;
|
||||
}
|
||||
revalidator.revalidate();
|
||||
};
|
||||
}, [dismissNewRuns, has, replace, revalidator, showNewRunsRef]);
|
||||
useEffect(() => {
|
||||
onNewRunsCountChange(newRunsCount);
|
||||
}, [newRunsCount, onNewRunsCountChange]);
|
||||
|
||||
@@ -155,7 +155,7 @@ export function TaskRunsTable({
|
||||
}
|
||||
}
|
||||
},
|
||||
[checkboxes, runs]
|
||||
[checkboxes, runs, select]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -264,31 +264,30 @@ function TagsDropdown({
|
||||
};
|
||||
|
||||
const fetcher = useFetcher<typeof tagsLoader>();
|
||||
const { load } = fetcher;
|
||||
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (searchValue) {
|
||||
searchParams.set("name", searchValue);
|
||||
}
|
||||
fetcher.load(
|
||||
load(
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/waitpoints/tags?${searchParams}`
|
||||
);
|
||||
}, [searchValue]);
|
||||
}, [environment.slug, load, organization.slug, project.slug, searchValue]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let items: string[] = [];
|
||||
if (searchValue === "") {
|
||||
items = values("tags");
|
||||
}
|
||||
|
||||
if (fetcher.data === undefined) {
|
||||
return matchSorter(items, searchValue);
|
||||
}
|
||||
let items: string[] = [];
|
||||
if (searchValue === "") {
|
||||
items = values("tags");
|
||||
}
|
||||
|
||||
let filtered: string[];
|
||||
if (fetcher.data === undefined) {
|
||||
filtered = matchSorter(items, searchValue);
|
||||
} else {
|
||||
items.push(...fetcher.data.tags.map((t) => t.name));
|
||||
|
||||
return matchSorter(Array.from(new Set(items)), searchValue);
|
||||
}, [searchValue, fetcher.data]);
|
||||
filtered = matchSorter(Array.from(new Set(items)), searchValue);
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("tags")} setValue={handleChange} virtualFocus={true}>
|
||||
|
||||
@@ -288,8 +288,8 @@ export function ToolUseRow({ tool }: { tool: ToolUse }) {
|
||||
|
||||
// Auto-select input tab when input arrives after initial render (e.g. streaming tool calls)
|
||||
useEffect(() => {
|
||||
if (!hasSubAgent && hasInput && activeTab === null) {
|
||||
setActiveTab("input");
|
||||
if (!hasSubAgent && hasInput) {
|
||||
setActiveTab((current) => current ?? "input");
|
||||
}
|
||||
}, [hasInput, hasSubAgent]);
|
||||
|
||||
|
||||
@@ -40,12 +40,12 @@ export function SampleSourcePicker({
|
||||
}, [bodyFetcher.data, onLoad]);
|
||||
|
||||
const manifest = listFetcher.data?.kind === "manifest" ? listFetcher.data : undefined;
|
||||
const providers = manifest?.providers ?? [];
|
||||
const samples = manifest?.samples ?? [];
|
||||
const providers = manifest?.providers;
|
||||
const samples = manifest?.samples;
|
||||
const listLoading = listFetcher.data === undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (providers.length === 0) return;
|
||||
if (!providers || providers.length === 0) return;
|
||||
setSelectedProvider((current) => {
|
||||
if (current && providers.some((p) => p.id === current)) return current;
|
||||
if (endpointSource && providers.some((p) => p.id === endpointSource)) return endpointSource;
|
||||
@@ -54,6 +54,8 @@ export function SampleSourcePicker({
|
||||
}, [providers, endpointSource]);
|
||||
|
||||
const filteredProviders = useMemo(() => {
|
||||
if (!providers) return [];
|
||||
|
||||
const query = producerQuery.trim().toLowerCase();
|
||||
if (!query) return providers;
|
||||
return providers.filter(
|
||||
@@ -72,7 +74,7 @@ export function SampleSourcePicker({
|
||||
return [...groups.entries()];
|
||||
}, [filteredProviders]);
|
||||
|
||||
const events = samples
|
||||
const events = (samples ?? [])
|
||||
.filter((item) => item.provider === selectedProvider)
|
||||
.filter((item) => {
|
||||
const query = topicQuery.trim().toLowerCase();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRevalidator } from "@remix-run/react";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type UseAutoRevalidateOptions = {
|
||||
interval?: number; // in milliseconds
|
||||
@@ -10,15 +10,17 @@ type UseAutoRevalidateOptions = {
|
||||
export function useAutoRevalidate(options: UseAutoRevalidateOptions = {}) {
|
||||
const { interval = 5000, onFocus = true, disabled = false } = options;
|
||||
const revalidator = useRevalidator();
|
||||
const revalidatorRef = useRef(revalidator);
|
||||
revalidatorRef.current = revalidator;
|
||||
|
||||
useEffect(() => {
|
||||
if (!interval || interval <= 0 || disabled) return;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
if (revalidator.state === "loading") {
|
||||
if (revalidatorRef.current.state === "loading") {
|
||||
return;
|
||||
}
|
||||
revalidator.revalidate();
|
||||
revalidatorRef.current.revalidate();
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
@@ -28,8 +30,8 @@ export function useAutoRevalidate(options: UseAutoRevalidateOptions = {}) {
|
||||
if (!onFocus || disabled) return;
|
||||
|
||||
const handleFocus = () => {
|
||||
if (document.visibilityState === "visible" && revalidator.state !== "loading") {
|
||||
revalidator.revalidate();
|
||||
if (document.visibilityState === "visible" && revalidatorRef.current.state !== "loading") {
|
||||
revalidatorRef.current.revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -7,20 +7,24 @@ export function useChanged<T extends { id: string }>(
|
||||
sendInitialUndefined = true
|
||||
) {
|
||||
const previousItemId = useRef<string | undefined>();
|
||||
const isInitialRender = useRef(true);
|
||||
const actionRef = useRef(action);
|
||||
const itemRef = useRef<T | undefined>();
|
||||
const item = getItem();
|
||||
const itemId = item?.id;
|
||||
|
||||
actionRef.current = action;
|
||||
itemRef.current = item;
|
||||
|
||||
//when the value changes, call the action
|
||||
useEffect(() => {
|
||||
if (previousItemId.current !== item?.id) {
|
||||
action(item);
|
||||
const shouldSendInitialUndefined =
|
||||
isInitialRender.current && itemId === undefined && sendInitialUndefined;
|
||||
|
||||
if (previousItemId.current !== itemId || shouldSendInitialUndefined) {
|
||||
actionRef.current(itemRef.current);
|
||||
}
|
||||
|
||||
previousItemId.current = item?.id;
|
||||
}, [item]);
|
||||
|
||||
//if sendInitialUndefined is true, call the action when the component first renders
|
||||
useEffect(() => {
|
||||
if (item !== undefined || sendInitialUndefined === false) return;
|
||||
action(item);
|
||||
}, []);
|
||||
previousItemId.current = itemId;
|
||||
isInitialRender.current = false;
|
||||
}, [itemId, sendInitialUndefined]);
|
||||
}
|
||||
|
||||
@@ -206,6 +206,8 @@ export function useDashboardEditor({
|
||||
const layoutDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isInitializedRef = useRef(false);
|
||||
const currentLayoutJsonRef = useRef<string>(JSON.stringify(initialData.layout));
|
||||
const initialDataRef = useRef(initialData);
|
||||
initialDataRef.current = initialData;
|
||||
|
||||
// Sync queue to prevent race conditions
|
||||
const syncQueueRef = useRef<SyncTask[]>([]);
|
||||
@@ -217,6 +219,8 @@ export function useDashboardEditor({
|
||||
widgets: initialData.widgets,
|
||||
});
|
||||
useEffect(() => {
|
||||
const { layout, widgets } = initialDataRef.current;
|
||||
|
||||
// Cancel any pending layout save
|
||||
if (layoutDebounceRef.current) {
|
||||
clearTimeout(layoutDebounceRef.current);
|
||||
@@ -229,11 +233,11 @@ export function useDashboardEditor({
|
||||
// Reset state to new initial data
|
||||
dispatch({
|
||||
type: "RESET_STATE",
|
||||
payload: { layout: initialData.layout, widgets: initialData.widgets },
|
||||
payload: { layout, widgets },
|
||||
});
|
||||
|
||||
// Update refs
|
||||
currentLayoutJsonRef.current = JSON.stringify(initialData.layout);
|
||||
currentLayoutJsonRef.current = JSON.stringify(layout);
|
||||
isInitializedRef.current = false;
|
||||
|
||||
// Allow saves after a short delay to skip initial mount callbacks
|
||||
|
||||
@@ -55,7 +55,7 @@ export function useFuzzyFilter<T extends object>({
|
||||
}),
|
||||
items
|
||||
);
|
||||
}, [items, filterText]);
|
||||
}, [items, keys, filterText]);
|
||||
|
||||
return {
|
||||
filterText,
|
||||
|
||||
@@ -38,7 +38,7 @@ export const usePostHog = (
|
||||
},
|
||||
});
|
||||
postHogInitialized.current = true;
|
||||
}, [apiKey, uiHost, logging, user]);
|
||||
}, [apiKey, uiHost, logging, debug, user]);
|
||||
|
||||
useUserChanged((user) => {
|
||||
if (postHogInitialized.current === false) return;
|
||||
|
||||
@@ -20,7 +20,7 @@ export function useReplaceSearchParams() {
|
||||
return s;
|
||||
}, navigateOpts);
|
||||
},
|
||||
[searchParams]
|
||||
[setSearchParams]
|
||||
);
|
||||
|
||||
return { searchParams, setSearchParams, replaceSearchParam };
|
||||
|
||||
+2
-1
@@ -214,6 +214,7 @@ const TASK_TYPE_SEGMENTS: {
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const TASK_FILTER_KEYS = ["slug", "filePath", "triggerSource"];
|
||||
|
||||
export default function Page() {
|
||||
const organization = useOrganization();
|
||||
@@ -266,7 +267,7 @@ export default function Page() {
|
||||
|
||||
const { filteredItems } = useFuzzyFilter<UnifiedTaskListItem>({
|
||||
items,
|
||||
keys: ["slug", "filePath", "triggerSource"],
|
||||
keys: TASK_FILTER_KEYS,
|
||||
filterText: value("search") ?? "",
|
||||
});
|
||||
|
||||
|
||||
+14
-11
@@ -505,17 +505,20 @@ export function BranchFilters() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { showArchived } = BranchesOptions.parse(Object.fromEntries(searchParams.entries()));
|
||||
|
||||
const handleArchivedChange = useCallback((checked: boolean) => {
|
||||
setSearchParams((s) => {
|
||||
if (checked) {
|
||||
s.set("showArchived", "true");
|
||||
} else {
|
||||
s.delete("showArchived");
|
||||
}
|
||||
s.delete("page");
|
||||
return s;
|
||||
});
|
||||
}, []);
|
||||
const handleArchivedChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
setSearchParams((s) => {
|
||||
if (checked) {
|
||||
s.set("showArchived", "true");
|
||||
} else {
|
||||
s.delete("showArchived");
|
||||
}
|
||||
s.delete("page");
|
||||
return s;
|
||||
});
|
||||
},
|
||||
[setSearchParams]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
|
||||
+3
-3
@@ -644,17 +644,17 @@ function PurchaseConcurrencyModal({
|
||||
// Close the panel, when we've succeeded
|
||||
// This is required because a redirect to the same path doesn't clear state
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const purchaseSucceeded = Boolean(searchParams.get("success"));
|
||||
const [open, setOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
const success = searchParams.get("success");
|
||||
if (success) {
|
||||
if (purchaseSucceeded) {
|
||||
setOpen(false);
|
||||
setSearchParams((s) => {
|
||||
s.delete("success");
|
||||
return s;
|
||||
});
|
||||
}
|
||||
}, [searchParams.get("success")]);
|
||||
}, [purchaseSucceeded, setSearchParams]);
|
||||
|
||||
const state = updateState({
|
||||
value: amountValue,
|
||||
|
||||
+7
-1
@@ -300,7 +300,13 @@ export default function Page() {
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [eventStream?.s2?.basin, eventStream?.s2?.stream, eventStream?.s2?.accessToken, isPending]);
|
||||
}, [
|
||||
eventStream?.s2?.basin,
|
||||
eventStream?.s2?.stream,
|
||||
eventStream?.s2?.accessToken,
|
||||
isPending,
|
||||
logsDisabled,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright">
|
||||
|
||||
+12
-3
@@ -191,15 +191,24 @@ export default function Page() {
|
||||
|
||||
useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true });
|
||||
|
||||
const selectedDeploymentShortCode = selectedDeployment?.shortCode;
|
||||
|
||||
// If we have a selected deployment from the version param, show it
|
||||
useEffect(() => {
|
||||
if (selectedDeployment && !deploymentParam) {
|
||||
if (selectedDeploymentShortCode && !deploymentParam) {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
searchParams.delete("version");
|
||||
searchParams.set("page", currentPage.toString());
|
||||
navigate(`${location.pathname}/${selectedDeployment.shortCode}?${searchParams.toString()}`);
|
||||
navigate(`${location.pathname}/${selectedDeploymentShortCode}?${searchParams.toString()}`);
|
||||
}
|
||||
}, [selectedDeployment, deploymentParam, location.search]);
|
||||
}, [
|
||||
selectedDeploymentShortCode,
|
||||
deploymentParam,
|
||||
location.search,
|
||||
location.pathname,
|
||||
currentPage,
|
||||
navigate,
|
||||
]);
|
||||
|
||||
const currentDeployment = deployments.find((d) => d.isCurrent);
|
||||
|
||||
|
||||
+14
-11
@@ -311,17 +311,20 @@ export function BranchFilters() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { showArchived } = BranchesOptions.parse(Object.fromEntries(searchParams.entries()));
|
||||
|
||||
const handleArchivedChange = useCallback((checked: boolean) => {
|
||||
setSearchParams((s) => {
|
||||
if (checked) {
|
||||
s.set("showArchived", "true");
|
||||
} else {
|
||||
s.delete("showArchived");
|
||||
}
|
||||
s.delete("page");
|
||||
return s;
|
||||
});
|
||||
}, []);
|
||||
const handleArchivedChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
setSearchParams((s) => {
|
||||
if (checked) {
|
||||
s.set("showArchived", "true");
|
||||
} else {
|
||||
s.delete("showArchived");
|
||||
}
|
||||
s.delete("page");
|
||||
return s;
|
||||
});
|
||||
},
|
||||
[setSearchParams]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
|
||||
+3
-3
@@ -10,7 +10,7 @@ import {
|
||||
import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import dotenv from "dotenv";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import { z } from "zod";
|
||||
@@ -570,7 +570,7 @@ function VariableFields({
|
||||
insertAfter,
|
||||
} = useList<Variable>([{ key: "", value: "" }]);
|
||||
|
||||
const handlePaste = useCallback((index: number, e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const handlePaste = (index: number, e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const clipboardData = e.clipboardData;
|
||||
if (!clipboardData) return;
|
||||
|
||||
@@ -593,7 +593,7 @@ function VariableFields({
|
||||
form.insert({ name: variablesFields.name });
|
||||
}
|
||||
insertAfter(index, rest);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const fields = variablesFields.getFieldList();
|
||||
|
||||
|
||||
+4
-3
@@ -100,6 +100,8 @@ export const meta = pageMeta(({ params }) => [
|
||||
"Errors",
|
||||
]);
|
||||
|
||||
const ERROR_CHART_COLORS = ["#6c5ce7", "#ec4899"];
|
||||
|
||||
const emptyStringToUndefined = z.preprocess(
|
||||
(v) => (v === "" ? undefined : v),
|
||||
z.coerce.number().positive().optional()
|
||||
@@ -327,9 +329,9 @@ export default function Page() {
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
|
||||
const location = useOptimisticLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
|
||||
const errorsPath = useMemo(() => {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const base = v3ErrorsPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
@@ -347,7 +349,7 @@ export default function Page() {
|
||||
}
|
||||
const qs = carry.toString();
|
||||
return qs ? `${base}?${qs}` : base;
|
||||
}, [organizationSlug, projectParam, envParam, searchParams.toString()]);
|
||||
}, [organizationSlug, projectParam, envParam, location.search]);
|
||||
|
||||
const alertsHref = useMemo(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
@@ -856,7 +858,6 @@ function ActivityChart({
|
||||
activity: ErrorGroupActivity;
|
||||
versions: ErrorGroupActivityVersions;
|
||||
}) {
|
||||
const ERROR_CHART_COLORS = ["#6c5ce7", "#ec4899"];
|
||||
const colors = useMemo(
|
||||
() => versions.map((_, i) => ERROR_CHART_COLORS[i % ERROR_CHART_COLORS.length]),
|
||||
[versions]
|
||||
|
||||
+2
-2
@@ -567,9 +567,9 @@ function ErrorGroupRow({
|
||||
envParam: string;
|
||||
}) {
|
||||
const location = useOptimisticLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
|
||||
const errorPath = useMemo(() => {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const base = v3ErrorPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
@@ -588,7 +588,7 @@ function ErrorGroupRow({
|
||||
}
|
||||
const qs = carry.toString();
|
||||
return qs ? `${base}?${qs}` : base;
|
||||
}, [organizationSlug, projectParam, envParam, errorGroup.fingerprint, searchParams.toString()]);
|
||||
}, [organizationSlug, projectParam, envParam, errorGroup.fingerprint, location.search]);
|
||||
|
||||
const errorMessage = `${errorGroup.errorMessage}`;
|
||||
|
||||
|
||||
+14
-5
@@ -756,6 +756,14 @@ function CompareDialog({
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const fetcher = useFetcher<typeof compareLoader>();
|
||||
const loadComparison = fetcher.load;
|
||||
const wasOpenRef = useRef(false);
|
||||
const canCompare = models.length >= 2;
|
||||
const comparisonPath = `${v3ModelComparePath(
|
||||
organization,
|
||||
project,
|
||||
environment
|
||||
)}?models=${models.join(",")}`;
|
||||
|
||||
const comparison = (fetcher.data as { comparison?: ModelComparisonItem[] } | undefined)
|
||||
?.comparison;
|
||||
@@ -764,13 +772,14 @@ function CompareDialog({
|
||||
[comparison, models, catalogModels]
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only fires on open; other deps are stable per dialog mount
|
||||
useEffect(() => {
|
||||
if (open && models.length >= 2) {
|
||||
const params = models.join(",");
|
||||
fetcher.load(`${v3ModelComparePath(organization, project, environment)}?models=${params}`);
|
||||
const wasOpen = wasOpenRef.current;
|
||||
wasOpenRef.current = open;
|
||||
|
||||
if (open && !wasOpen && canCompare) {
|
||||
loadComparison(comparisonPath);
|
||||
}
|
||||
}, [open]);
|
||||
}, [open, canCompare, comparisonPath, loadComparison]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
||||
+3
-3
@@ -22,7 +22,7 @@ import {
|
||||
} from "@trigger.dev/core/v3";
|
||||
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { motion } from "framer-motion";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { ChevronExtraSmallDown } from "~/assets/icons/ChevronExtraSmallDown";
|
||||
@@ -1966,10 +1966,10 @@ function SearchField({ onChange }: { onChange: (value: string) => void }) {
|
||||
onChange(text);
|
||||
}, 250);
|
||||
|
||||
const updateValue = useCallback((next: string) => {
|
||||
const updateValue = (next: string) => {
|
||||
setValue(next);
|
||||
updateFilterText(next);
|
||||
}, []);
|
||||
};
|
||||
|
||||
return <SearchInput placeholder="Search logs…" value={value} onValueChange={updateValue} />;
|
||||
}
|
||||
|
||||
+18
-30
@@ -220,7 +220,14 @@ export default function IntegrationsSettingsPage() {
|
||||
const nextUrl = searchParams.get("next");
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const vercelFetcher = useTypedFetcher<typeof vercelLoader>();
|
||||
const loadVercelOnboarding = vercelFetcher.load;
|
||||
const onboardingData = vercelFetcher.data?.onboardingData ?? null;
|
||||
const hasVercelFetcherData = vercelFetcher.data !== undefined;
|
||||
const vercelOnboardingPath = `${vercelResourcePath(
|
||||
organization.slug,
|
||||
project.slug,
|
||||
environment.slug
|
||||
)}?vercelOnboarding=true`;
|
||||
|
||||
// Helper to open modal and ensure query param is present
|
||||
const openVercelOnboarding = useCallback(() => {
|
||||
@@ -256,15 +263,9 @@ export default function IntegrationsSettingsPage() {
|
||||
if (!isModalOpen) {
|
||||
openVercelOnboarding();
|
||||
}
|
||||
} else if (vercelFetcher.state === "idle" && vercelFetcher.data === undefined) {
|
||||
} else if (vercelFetcher.state === "idle" && !hasVercelFetcherData) {
|
||||
// Load onboarding data
|
||||
vercelFetcher.load(
|
||||
`${vercelResourcePath(
|
||||
organization.slug,
|
||||
project.slug,
|
||||
environment.slug
|
||||
)}?vercelOnboarding=true`
|
||||
);
|
||||
loadVercelOnboarding(vercelOnboardingPath);
|
||||
}
|
||||
} else if (!hasQueryParam && isModalOpen) {
|
||||
// Query param removed but modal is open, close modal
|
||||
@@ -273,14 +274,13 @@ export default function IntegrationsSettingsPage() {
|
||||
}, [
|
||||
hasQueryParam,
|
||||
vercelIntegrationEnabled,
|
||||
organization.slug,
|
||||
project.slug,
|
||||
environment.slug,
|
||||
onboardingData,
|
||||
vercelFetcher.data,
|
||||
hasVercelFetcherData,
|
||||
vercelFetcher.state,
|
||||
isModalOpen,
|
||||
openVercelOnboarding,
|
||||
loadVercelOnboarding,
|
||||
vercelOnboardingPath,
|
||||
]);
|
||||
|
||||
// Ensure modal stays open when query param is present (even after data reloads)
|
||||
@@ -301,7 +301,7 @@ export default function IntegrationsSettingsPage() {
|
||||
openVercelOnboarding();
|
||||
}
|
||||
}
|
||||
}, [hasQueryParam, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
|
||||
}, [hasQueryParam, onboardingData, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
|
||||
|
||||
// Track if we're waiting for data from button click (not query param)
|
||||
const waitingForButtonClickRef = useRef(false);
|
||||
@@ -322,19 +322,11 @@ export default function IntegrationsSettingsPage() {
|
||||
} else {
|
||||
// Need to load data first, mark that we're waiting for button click
|
||||
waitingForButtonClickRef.current = true;
|
||||
vercelFetcher.load(
|
||||
`${vercelResourcePath(
|
||||
organization.slug,
|
||||
project.slug,
|
||||
environment.slug
|
||||
)}?vercelOnboarding=true`
|
||||
);
|
||||
loadVercelOnboarding(vercelOnboardingPath);
|
||||
}
|
||||
}, [
|
||||
organization.slug,
|
||||
project.slug,
|
||||
environment.slug,
|
||||
vercelFetcher,
|
||||
loadVercelOnboarding,
|
||||
vercelOnboardingPath,
|
||||
onboardingData,
|
||||
setSearchParams,
|
||||
hasQueryParam,
|
||||
@@ -415,12 +407,8 @@ export default function IntegrationsSettingsPage() {
|
||||
nextUrl={nextUrl ?? undefined}
|
||||
vercelManageAccessUrl={vercelFetcher.data?.vercelManageAccessUrl}
|
||||
onDataReload={(vercelEnvironmentId) => {
|
||||
vercelFetcher.load(
|
||||
`${vercelResourcePath(
|
||||
organization.slug,
|
||||
project.slug,
|
||||
environment.slug
|
||||
)}?vercelOnboarding=true${
|
||||
loadVercelOnboarding(
|
||||
`${vercelOnboardingPath}${
|
||||
vercelEnvironmentId
|
||||
? `&vercelEnvironmentId=${encodeURIComponent(vercelEnvironmentId)}`
|
||||
: ""
|
||||
|
||||
+21
-11
@@ -553,6 +553,7 @@ function CreateScheduleSheet({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const fetcher = useTypedFetcher<typeof scheduleNewLoader>();
|
||||
const loadScheduleForm = fetcher.load;
|
||||
// Embedded create — stays on this page via `_format=json`.
|
||||
const createFetcher = useFetcher<{ ok: boolean; message?: string }>();
|
||||
const toast = useToast();
|
||||
@@ -563,8 +564,8 @@ function CreateScheduleSheet({
|
||||
const newPath = v3NewSchedulePath(organization, project, environment);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) fetcher.load(newPath);
|
||||
}, [open, newPath]);
|
||||
if (open) loadScheduleForm(newPath);
|
||||
}, [open, newPath, loadScheduleForm]);
|
||||
|
||||
// Toast + close + revalidate so the new schedule appears.
|
||||
useEffect(() => {
|
||||
@@ -624,7 +625,9 @@ function ScheduleSheet({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const detailFetcher = useTypedFetcher<typeof scheduleDetailLoader>();
|
||||
const loadScheduleDetail = detailFetcher.load;
|
||||
const editFetcher = useTypedFetcher<typeof scheduleEditLoader>();
|
||||
const loadScheduleEditor = editFetcher.load;
|
||||
// Embedded enable/disable — stays in the sheet via `_format=json`.
|
||||
const activeToggleFetcher = useFetcher<{ ok: boolean; active?: boolean; message?: string }>();
|
||||
// Embedded update submission — same idea.
|
||||
@@ -652,12 +655,12 @@ function ScheduleSheet({
|
||||
}, [openScheduleId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (detailPath) detailFetcher.load(detailPath);
|
||||
}, [detailPath]);
|
||||
if (detailPath) loadScheduleDetail(detailPath);
|
||||
}, [detailPath, loadScheduleDetail]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && editPath) editFetcher.load(editPath);
|
||||
}, [mode, editPath]);
|
||||
if (mode === "edit" && editPath) loadScheduleEditor(editPath);
|
||||
}, [mode, editPath, loadScheduleEditor]);
|
||||
|
||||
// Reload inspector data so Enable/Disable label flips; revalidate the
|
||||
// route loader so the sidebar's list/Overview stay in sync; toast on error.
|
||||
@@ -667,12 +670,19 @@ function ScheduleSheet({
|
||||
if (handledToggleRef.current === data) return;
|
||||
handledToggleRef.current = data;
|
||||
if (data.ok) {
|
||||
if (detailPath) detailFetcher.load(detailPath);
|
||||
if (detailPath) loadScheduleDetail(detailPath);
|
||||
revalidator.revalidate();
|
||||
} else if (data.message) {
|
||||
toast.error(data.message);
|
||||
}
|
||||
}, [activeToggleFetcher.state, activeToggleFetcher.data, detailPath, toast, revalidator]);
|
||||
}, [
|
||||
activeToggleFetcher.state,
|
||||
activeToggleFetcher.data,
|
||||
detailPath,
|
||||
toast,
|
||||
revalidator,
|
||||
loadScheduleDetail,
|
||||
]);
|
||||
|
||||
// Toast + back to inspect + reload + revalidate so both the inspector
|
||||
// and the sidebar reflect the update.
|
||||
@@ -684,12 +694,12 @@ function ScheduleSheet({
|
||||
if (data.ok) {
|
||||
toast.success(data.message ?? "Schedule updated");
|
||||
setMode("inspect");
|
||||
if (detailPath) detailFetcher.load(detailPath);
|
||||
if (detailPath) loadScheduleDetail(detailPath);
|
||||
revalidator.revalidate();
|
||||
} else if (data.message) {
|
||||
toast.error(data.message);
|
||||
}
|
||||
}, [updateFetcher.state, updateFetcher.data, detailPath, toast, revalidator]);
|
||||
}, [updateFetcher.state, updateFetcher.data, detailPath, toast, revalidator, loadScheduleDetail]);
|
||||
|
||||
// Toast + close + revalidate so the deleted row disappears.
|
||||
useEffect(() => {
|
||||
@@ -792,7 +802,7 @@ function ScheduledTaskDetailSidebar({
|
||||
if (a.type === b.type) return 0;
|
||||
return a.type === "DECLARATIVE" ? -1 : 1;
|
||||
});
|
||||
}, [scheduleList?.schedules]);
|
||||
}, [scheduleList]);
|
||||
const firstSchedule = sortedSchedules[0];
|
||||
const [activeTab, setActiveTab] = useState<"overview" | "schedules">("overview");
|
||||
return (
|
||||
|
||||
+20
-22
@@ -63,6 +63,25 @@ export function AIPayloadTabContent({
|
||||
|
||||
const submitGeneration = useCallback(
|
||||
async (queryPrompt: string) => {
|
||||
const processStreamEvent = (event: StreamEventType) => {
|
||||
switch (event.type) {
|
||||
case "thinking":
|
||||
setThinking((prev) => prev + event.content);
|
||||
break;
|
||||
case "result":
|
||||
if (event.success) {
|
||||
onPayloadGenerated(event.payload);
|
||||
setLastPayload(event.payload);
|
||||
setPrompt("");
|
||||
setLastResult("success");
|
||||
} else {
|
||||
setError(event.error);
|
||||
setLastResult("error");
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if (!queryPrompt.trim() || isLoadingRef.current) return;
|
||||
|
||||
isLoadingRef.current = true;
|
||||
@@ -168,31 +187,10 @@ export function AIPayloadTabContent({
|
||||
isAgent,
|
||||
payloadKind,
|
||||
providerSource,
|
||||
onPayloadGenerated,
|
||||
]
|
||||
);
|
||||
|
||||
const processStreamEvent = useCallback(
|
||||
(event: StreamEventType) => {
|
||||
switch (event.type) {
|
||||
case "thinking":
|
||||
setThinking((prev) => prev + event.content);
|
||||
break;
|
||||
case "result":
|
||||
if (event.success) {
|
||||
onPayloadGenerated(event.payload);
|
||||
setLastPayload(event.payload);
|
||||
setPrompt("");
|
||||
setLastResult("success");
|
||||
} else {
|
||||
setError(event.error);
|
||||
setLastResult("error");
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[onPayloadGenerated]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
|
||||
+14
-4
@@ -297,6 +297,7 @@ export default function Page() {
|
||||
|
||||
const params = useParams();
|
||||
const queueFetcher = useFetcher<typeof queuesLoader>();
|
||||
const { load: loadQueues } = queueFetcher;
|
||||
|
||||
useEffect(() => {
|
||||
if (result.foundTask && params.organizationSlug && params.projectParam && params.envParam) {
|
||||
@@ -304,13 +305,13 @@ export default function Page() {
|
||||
searchParams.set("type", "custom");
|
||||
searchParams.set("per_page", "100");
|
||||
|
||||
queueFetcher.load(
|
||||
loadQueues(
|
||||
`/resources/orgs/${params.organizationSlug}/projects/${params.projectParam}/env/${
|
||||
params.envParam
|
||||
}/queues?${searchParams.toString()}`
|
||||
);
|
||||
}
|
||||
}, [result.foundTask, params.organizationSlug, params.projectParam, params.envParam]);
|
||||
}, [result.foundTask, params.organizationSlug, params.projectParam, params.envParam, loadQueues]);
|
||||
|
||||
const defaultTaskQueue = result.foundTask && "queue" in result ? result.queue : undefined;
|
||||
const queues = useMemo(() => {
|
||||
@@ -1758,6 +1759,7 @@ function CreateTemplateModal({
|
||||
}) {
|
||||
const submit = useSubmit();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const successMessageTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const actionData = useActionData<typeof action>();
|
||||
const lastSubmission =
|
||||
@@ -1772,11 +1774,19 @@ function CreateTemplateModal({
|
||||
if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) {
|
||||
setIsModalOpen(false);
|
||||
setShowCreatedSuccessMessage(true);
|
||||
setTimeout(() => {
|
||||
clearTimeout(successMessageTimeoutRef.current);
|
||||
successMessageTimeoutRef.current = setTimeout(() => {
|
||||
setShowCreatedSuccessMessage(false);
|
||||
}, 2000);
|
||||
}
|
||||
}, [lastSubmission]);
|
||||
}, [lastSubmission, setShowCreatedSuccessMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(successMessageTimeoutRef.current);
|
||||
setShowCreatedSuccessMessage(false);
|
||||
};
|
||||
}, [setShowCreatedSuccessMessage]);
|
||||
|
||||
const [
|
||||
form,
|
||||
|
||||
+3
-1
@@ -41,6 +41,8 @@ import { testAgentPageContext } from "~/components/dashboard-agent/suggested-pro
|
||||
import { WhenAgentUnavailable } from "~/components/dashboard-agent/WhenAgentUnavailable";
|
||||
import type { Handle } from "~/utils/handle";
|
||||
|
||||
const TASK_FILTER_KEYS = ["taskIdentifier", "friendlyId", "id", "filePath", "triggerSource"];
|
||||
|
||||
export const handle: Handle = {
|
||||
agentPageContext: (data) => testAgentPageContext(data),
|
||||
};
|
||||
@@ -138,7 +140,7 @@ function TaskSelector({
|
||||
}) {
|
||||
const { filterText, setFilterText, filteredItems } = useFuzzyFilter<TaskListItem>({
|
||||
items: tasks,
|
||||
keys: ["taskIdentifier", "friendlyId", "id", "filePath", "triggerSource"],
|
||||
keys: TASK_FILTER_KEYS,
|
||||
});
|
||||
const hasTaskInEnvironment = activeTaskIdentifier
|
||||
? tasks.some((t) => t.taskIdentifier === activeTaskIdentifier)
|
||||
|
||||
@@ -178,7 +178,7 @@ export default function AdminFeatureFlagsRoute() {
|
||||
// Only track editable flags in state
|
||||
const editable: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(loaded)) {
|
||||
if (!isLocked(key)) {
|
||||
if (unlocked || !GLOBAL_LOCKED_FLAGS.includes(key)) {
|
||||
editable[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useFetcher, useRevalidator } from "@remix-run/react";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
@@ -57,11 +57,12 @@ export const action = dashboardAction(
|
||||
export default function AdminQueueMetricsRoute() {
|
||||
const { controls, streams } = useTypedLoaderData<typeof loader>();
|
||||
const saveFetcher = useFetcher<{ success?: boolean; error?: string }>();
|
||||
const revalidator = useRevalidator();
|
||||
const { revalidate, state: revalidatorState } = useRevalidator();
|
||||
|
||||
const [enabled, setEnabled] = useState(controls.enabled);
|
||||
const [sampleRate, setSampleRate] = useState(String(controls.sampleRate));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const handledSaveDataRef = useRef(saveFetcher.data);
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(controls.enabled);
|
||||
@@ -69,13 +70,18 @@ export default function AdminQueueMetricsRoute() {
|
||||
}, [controls.enabled, controls.sampleRate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (saveFetcher.data?.success) {
|
||||
if (!saveFetcher.data || handledSaveDataRef.current === saveFetcher.data) {
|
||||
return;
|
||||
}
|
||||
handledSaveDataRef.current = saveFetcher.data;
|
||||
|
||||
if (saveFetcher.data.success) {
|
||||
setError(null);
|
||||
revalidator.revalidate();
|
||||
} else if (saveFetcher.data?.error) {
|
||||
revalidate();
|
||||
} else if (saveFetcher.data.error) {
|
||||
setError(saveFetcher.data.error);
|
||||
}
|
||||
}, [saveFetcher.data]);
|
||||
}, [saveFetcher.data, revalidate]);
|
||||
|
||||
const isSaving = saveFetcher.state === "submitting";
|
||||
|
||||
@@ -142,8 +148,8 @@ export default function AdminQueueMetricsRoute() {
|
||||
<Header2>Stream health{totalLag > 0 ? ` (lag ${totalLag})` : ""}</Header2>
|
||||
<Button
|
||||
variant="tertiary/small"
|
||||
onClick={() => revalidator.revalidate()}
|
||||
disabled={revalidator.state === "loading"}
|
||||
onClick={revalidate}
|
||||
disabled={revalidatorState === "loading"}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { json } from "@remix-run/node";
|
||||
import { useFetcher, type ShouldRevalidateFunction } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLatest } from "react-use";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
|
||||
@@ -41,26 +42,32 @@ const POLL_INTERVAL_MS = 60_000;
|
||||
export function useIncidentStatus() {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const fetcher = useFetcher<typeof loader>();
|
||||
const { load, state } = fetcher;
|
||||
const stateRef = useLatest(state);
|
||||
const hasInitiallyFetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManagedCloud) return;
|
||||
|
||||
// Initial fetch on mount
|
||||
if (!hasInitiallyFetched.current && fetcher.state === "idle") {
|
||||
if (!hasInitiallyFetched.current && state === "idle") {
|
||||
hasInitiallyFetched.current = true;
|
||||
fetcher.load("/resources/incidents");
|
||||
load("/resources/incidents");
|
||||
}
|
||||
}, [isManagedCloud, load, state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManagedCloud) return;
|
||||
|
||||
// Poll every 60 seconds
|
||||
const interval = setInterval(() => {
|
||||
if (fetcher.state === "idle") {
|
||||
fetcher.load("/resources/incidents");
|
||||
if (stateRef.current === "idle") {
|
||||
load("/resources/incidents");
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isManagedCloud]);
|
||||
}, [isManagedCloud, load, stateRef]);
|
||||
|
||||
return {
|
||||
status: fetcher.data?.status ?? "operational",
|
||||
|
||||
@@ -202,12 +202,23 @@ export function MetricWidget({
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const isDirtyRef = useRef(false);
|
||||
const submitRef = useRef<() => void>(() => {});
|
||||
|
||||
// Track the latest props so the submit callback always uses fresh values
|
||||
// without needing to be recreated (which would cause useInterval to re-register listeners).
|
||||
const propsRef = useRef(props);
|
||||
propsRef.current = props;
|
||||
|
||||
// Track visibility so we only fetch for on-screen widgets.
|
||||
// When a widget scrolls into view and has no data yet, trigger a load.
|
||||
const { ref: visibilityRef, isVisibleRef } = useElementVisibility({
|
||||
onVisibilityChange: (visible) => {
|
||||
if (visible && (!response || isDirtyRef.current)) {
|
||||
submitRef.current();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (!isVisibleRef.current) {
|
||||
isDirtyRef.current = true;
|
||||
@@ -251,17 +262,8 @@ export function MetricWidget({
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Track visibility so we only fetch for on-screen widgets.
|
||||
// When a widget scrolls into view and has no data yet, trigger a load.
|
||||
const { ref: visibilityRef, isVisibleRef } = useElementVisibility({
|
||||
onVisibilityChange: (visible) => {
|
||||
if (visible && (!response || isDirtyRef.current)) {
|
||||
submit();
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [isVisibleRef]);
|
||||
submitRef.current = submit;
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(() => {
|
||||
@@ -273,24 +275,12 @@ export function MetricWidget({
|
||||
// Reload periodically and on focus (onLoad: false — the useEffect below handles initial load)
|
||||
useInterval({ interval: refreshIntervalMs, callback: submit, onLoad: false });
|
||||
|
||||
const reloadKey = JSON.stringify(props);
|
||||
|
||||
// Reload on mount and when query, time period, or filters change
|
||||
useEffect(() => {
|
||||
submit();
|
||||
}, [
|
||||
submit,
|
||||
props.query,
|
||||
props.from,
|
||||
props.to,
|
||||
props.period,
|
||||
props.scope,
|
||||
JSON.stringify(props.taskIdentifiers),
|
||||
JSON.stringify(props.queues),
|
||||
JSON.stringify(props.responseModels),
|
||||
JSON.stringify(props.promptSlugs),
|
||||
JSON.stringify(props.promptVersions),
|
||||
JSON.stringify(props.operations),
|
||||
JSON.stringify(props.providers),
|
||||
]);
|
||||
}, [submit, reloadKey]);
|
||||
|
||||
const data = response?.success
|
||||
? { rows: response.data.rows, columns: response.data.columns }
|
||||
|
||||
+3
-2
@@ -1077,6 +1077,7 @@ export function GitHubSettingsPanel({
|
||||
layout?: "settings" | "compact";
|
||||
}) {
|
||||
const fetcher = useTypedFetcher<typeof loader>();
|
||||
const { load } = fetcher;
|
||||
const location = useLocation();
|
||||
|
||||
// Preserve current search params (e.g. origin=marketplace, next=...) but strip
|
||||
@@ -1088,8 +1089,8 @@ export function GitHubSettingsPanel({
|
||||
return search ? `${location.pathname}?${search}` : location.pathname;
|
||||
})();
|
||||
useEffect(() => {
|
||||
fetcher.load(gitHubResourcePath(organizationSlug, projectSlug, environmentSlug));
|
||||
}, [organizationSlug, projectSlug, environmentSlug]);
|
||||
load(gitHubResourcePath(organizationSlug, projectSlug, environmentSlug));
|
||||
}, [organizationSlug, projectSlug, environmentSlug, load]);
|
||||
|
||||
const data = fetcher.data;
|
||||
|
||||
|
||||
+3
-2
@@ -229,14 +229,15 @@ export function SpanView({
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const fetcher = useTypedFetcher<typeof loader>();
|
||||
const { load } = fetcher;
|
||||
|
||||
useEffect(() => {
|
||||
if (spanId === undefined) return;
|
||||
const url = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
|
||||
environment.slug
|
||||
}/runs/${runParam}/spans/${spanId}${linkedRunId ? `?linkedRunId=${linkedRunId}` : ""}`;
|
||||
fetcher.load(url);
|
||||
}, [organization.slug, project.slug, environment.slug, runParam, spanId, linkedRunId]);
|
||||
load(url);
|
||||
}, [organization.slug, project.slug, environment.slug, runParam, spanId, linkedRunId, load]);
|
||||
|
||||
if (spanId === undefined) {
|
||||
return null;
|
||||
|
||||
+4
-9
@@ -255,22 +255,17 @@ export function CreateBulkActionInspector({
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const fetcher = useTypedFetcher<typeof loader>();
|
||||
const { load } = fetcher;
|
||||
const { value, replace, del } = useSearchParams();
|
||||
const [action, setAction] = useState<BulkActionAction>(
|
||||
bulkActionActionFromString(value("action"))
|
||||
);
|
||||
const action = bulkActionActionFromString(value("action"));
|
||||
const location = useOptimisticLocation();
|
||||
const user = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
fetcher.load(
|
||||
load(
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/bulkaction${location.search}`
|
||||
);
|
||||
}, [organization.id, project.id, environment.id, location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
setAction(bulkActionActionFromString(value("action")));
|
||||
}, [value("action")]);
|
||||
}, [organization.slug, project.slug, environment.slug, location.search, load]);
|
||||
|
||||
const mode = bulkActionModeFromString(value("mode"));
|
||||
|
||||
|
||||
+21
-31
@@ -695,7 +695,6 @@ function ConnectedVercelProjectForm({
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [hasConfigChanges, setHasConfigChanges] = useState(false);
|
||||
const [configValues, setConfigValues] = useState({
|
||||
atomicBuilds: connectedProject.integrationData.config.atomicBuilds ?? [],
|
||||
pullEnvVarsBeforeBuild: connectedProject.integrationData.config.pullEnvVarsBeforeBuild ?? [],
|
||||
@@ -712,35 +711,24 @@ function ConnectedVercelProjectForm({
|
||||
connectedProject.integrationData.config.vercelStagingEnvironment ?? null;
|
||||
const originalAutoPromote = connectedProject.integrationData.config.autoPromote ?? true;
|
||||
|
||||
useEffect(() => {
|
||||
const atomicBuildsChanged =
|
||||
JSON.stringify([...configValues.atomicBuilds].sort()) !==
|
||||
JSON.stringify([...originalAtomicBuilds].sort());
|
||||
const pullEnvVarsChanged =
|
||||
JSON.stringify([...configValues.pullEnvVarsBeforeBuild].sort()) !==
|
||||
JSON.stringify([...originalPullEnvVars].sort());
|
||||
const discoverEnvVarsChanged =
|
||||
JSON.stringify([...configValues.discoverEnvVars].sort()) !==
|
||||
JSON.stringify([...originalDiscoverEnvVars].sort());
|
||||
const stagingEnvChanged =
|
||||
configValues.vercelStagingEnvironment?.environmentId !== originalStagingEnv?.environmentId;
|
||||
const autoPromoteChanged = configValues.autoPromote !== originalAutoPromote;
|
||||
|
||||
setHasConfigChanges(
|
||||
atomicBuildsChanged ||
|
||||
pullEnvVarsChanged ||
|
||||
discoverEnvVarsChanged ||
|
||||
stagingEnvChanged ||
|
||||
autoPromoteChanged
|
||||
);
|
||||
}, [
|
||||
configValues,
|
||||
originalAtomicBuilds,
|
||||
originalPullEnvVars,
|
||||
originalDiscoverEnvVars,
|
||||
originalStagingEnv,
|
||||
originalAutoPromote,
|
||||
]);
|
||||
const atomicBuildsChanged =
|
||||
JSON.stringify([...configValues.atomicBuilds].sort()) !==
|
||||
JSON.stringify([...originalAtomicBuilds].sort());
|
||||
const pullEnvVarsChanged =
|
||||
JSON.stringify([...configValues.pullEnvVarsBeforeBuild].sort()) !==
|
||||
JSON.stringify([...originalPullEnvVars].sort());
|
||||
const discoverEnvVarsChanged =
|
||||
JSON.stringify([...configValues.discoverEnvVars].sort()) !==
|
||||
JSON.stringify([...originalDiscoverEnvVars].sort());
|
||||
const stagingEnvChanged =
|
||||
configValues.vercelStagingEnvironment?.environmentId !== originalStagingEnv?.environmentId;
|
||||
const autoPromoteChanged = configValues.autoPromote !== originalAutoPromote;
|
||||
const hasConfigChanges =
|
||||
atomicBuildsChanged ||
|
||||
pullEnvVarsChanged ||
|
||||
discoverEnvVarsChanged ||
|
||||
stagingEnvChanged ||
|
||||
autoPromoteChanged;
|
||||
|
||||
const [configForm, _fields] = useForm({
|
||||
id: "update-vercel-config",
|
||||
@@ -1121,6 +1109,7 @@ function VercelSettingsPanel({
|
||||
isLoadingVercelData?: boolean;
|
||||
}) {
|
||||
const fetcher = useTypedFetcher<typeof loader>();
|
||||
const { load } = fetcher;
|
||||
const _location = useLocation();
|
||||
const data = fetcher.data;
|
||||
const [hasError, _setHasError] = useState(false);
|
||||
@@ -1128,7 +1117,7 @@ function VercelSettingsPanel({
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.authInvalid && !hasError && !data && !hasFetched) {
|
||||
fetcher.load(vercelResourcePath(organizationSlug, projectSlug, environmentSlug));
|
||||
load(vercelResourcePath(organizationSlug, projectSlug, environmentSlug));
|
||||
setHasFetched(true);
|
||||
}
|
||||
}, [
|
||||
@@ -1139,6 +1128,7 @@ function VercelSettingsPanel({
|
||||
hasError,
|
||||
data,
|
||||
hasFetched,
|
||||
load,
|
||||
]);
|
||||
|
||||
if (hasError) {
|
||||
|
||||
+15
-18
@@ -5,7 +5,7 @@ import type { WaitpointTokenStatus } from "@trigger.dev/core/v3";
|
||||
import { stringifyIO, timeoutError } from "@trigger.dev/core/v3";
|
||||
import { WaitpointId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import type { Waitpoint } from "@trigger.dev/database";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useRef } from "react";
|
||||
import { z } from "zod";
|
||||
import { AnimatedHourglassIcon } from "~/assets/icons/AnimatedHourglassIcon";
|
||||
import { JSONEditor } from "~/components/code/JSONEditor";
|
||||
@@ -321,25 +321,22 @@ function CompleteManualWaitpointForm({ waitpoint }: { waitpoint: { id: string }
|
||||
const currentJson = useRef<string>("{\n\n}");
|
||||
const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/waitpoints/${waitpoint.id}/complete`;
|
||||
|
||||
const submitForm = useCallback(
|
||||
(e: React.FormEvent<HTMLFormElement>) => {
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const data: Record<string, string> = {
|
||||
type: formData.get("type") as string,
|
||||
failureRedirect: formData.get("failureRedirect") as string,
|
||||
successRedirect: formData.get("successRedirect") as string,
|
||||
};
|
||||
const submitForm = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const data: Record<string, string> = {
|
||||
type: formData.get("type") as string,
|
||||
failureRedirect: formData.get("failureRedirect") as string,
|
||||
successRedirect: formData.get("successRedirect") as string,
|
||||
};
|
||||
|
||||
data.payload = currentJson.current;
|
||||
data.payload = currentJson.current;
|
||||
|
||||
submit(data, {
|
||||
action: formAction,
|
||||
method: "post",
|
||||
});
|
||||
e.preventDefault();
|
||||
},
|
||||
[currentJson]
|
||||
);
|
||||
submit(data, {
|
||||
action: formAction,
|
||||
method: "post",
|
||||
});
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
|
||||
+6
-4
@@ -1,7 +1,7 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
@@ -58,6 +58,8 @@ type AIGeneratedCronFieldProps = {
|
||||
export function AIGeneratedCronField({ onSuccess }: AIGeneratedCronFieldProps) {
|
||||
const fetcher = useFetcher<typeof action>();
|
||||
const [text, setText] = useState<string>("");
|
||||
const onSuccessRef = useRef(onSuccess);
|
||||
onSuccessRef.current = onSuccess;
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const isLoading = fetcher.state !== "idle";
|
||||
@@ -66,11 +68,11 @@ export function AIGeneratedCronField({ onSuccess }: AIGeneratedCronFieldProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (resultData?.cron !== undefined) {
|
||||
onSuccess(resultData.cron);
|
||||
onSuccessRef.current(resultData.cron);
|
||||
}
|
||||
}, [resultData?.cron]);
|
||||
|
||||
const submit = useCallback(async (value: string) => {
|
||||
const submit = (value: string) => {
|
||||
fetcher.submit(
|
||||
{ message: value },
|
||||
{
|
||||
@@ -79,7 +81,7 @@ export function AIGeneratedCronField({ onSuccess }: AIGeneratedCronFieldProps) {
|
||||
encType: "application/json",
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json } from "@remix-run/node";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { useFetcher, type ShouldRevalidateFunction } from "@remix-run/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLatest } from "react-use";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { getRecentChangelogs, verifyOrgMembership } from "~/services/platformNotifications.server";
|
||||
@@ -42,28 +43,31 @@ const POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
export function useRecentChangelogs(organizationId?: string, projectId?: string) {
|
||||
const fetcher = useFetcher<typeof loader>();
|
||||
const { load, state } = fetcher;
|
||||
const stateRef = useLatest(state);
|
||||
const lastLoadedUrl = useRef<string | null>(null);
|
||||
const params = new URLSearchParams();
|
||||
if (organizationId) params.set("organizationId", organizationId);
|
||||
if (projectId) params.set("projectId", projectId);
|
||||
const qs = params.toString();
|
||||
const url = `/resources/platform-changelogs${qs ? `?${qs}` : ""}`;
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (organizationId) params.set("organizationId", organizationId);
|
||||
if (projectId) params.set("projectId", projectId);
|
||||
const qs = params.toString();
|
||||
const url = `/resources/platform-changelogs${qs ? `?${qs}` : ""}`;
|
||||
|
||||
if (lastLoadedUrl.current !== url && fetcher.state === "idle") {
|
||||
if (lastLoadedUrl.current !== url && state === "idle") {
|
||||
lastLoadedUrl.current = url;
|
||||
fetcher.load(url);
|
||||
load(url);
|
||||
}
|
||||
}, [load, state, url]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (fetcher.state === "idle") {
|
||||
fetcher.load(url);
|
||||
if (stateRef.current === "idle") {
|
||||
load(url);
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [organizationId, projectId]);
|
||||
}, [load, stateRef, url]);
|
||||
|
||||
return {
|
||||
changelogs: fetcher.data?.changelogs ?? [],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json } from "@remix-run/node";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { useFetcher, type ShouldRevalidateFunction } from "@remix-run/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLatest } from "react-use";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
getActivePlatformNotifications,
|
||||
@@ -41,24 +42,27 @@ const POLL_INTERVAL_MS = 60000; // 1 minute
|
||||
|
||||
export function usePlatformNotifications(organizationId: string, projectId: string) {
|
||||
const fetcher = useFetcher<typeof loader>();
|
||||
const { load, state } = fetcher;
|
||||
const stateRef = useLatest(state);
|
||||
const lastLoadedUrl = useRef<string | null>(null);
|
||||
const url = `/resources/platform-notifications?organizationId=${encodeURIComponent(organizationId)}&projectId=${encodeURIComponent(projectId)}`;
|
||||
|
||||
useEffect(() => {
|
||||
const url = `/resources/platform-notifications?organizationId=${encodeURIComponent(organizationId)}&projectId=${encodeURIComponent(projectId)}`;
|
||||
|
||||
if (lastLoadedUrl.current !== url && fetcher.state === "idle") {
|
||||
if (lastLoadedUrl.current !== url && state === "idle") {
|
||||
lastLoadedUrl.current = url;
|
||||
fetcher.load(url);
|
||||
load(url);
|
||||
}
|
||||
}, [load, state, url]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (fetcher.state === "idle") {
|
||||
fetcher.load(url);
|
||||
if (stateRef.current === "idle") {
|
||||
load(url);
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [organizationId, projectId]);
|
||||
}, [load, stateRef, url]);
|
||||
|
||||
return {
|
||||
notifications: fetcher.data?.notifications ?? [],
|
||||
|
||||
@@ -795,7 +795,6 @@ function AgentOrb({
|
||||
colors = AGENT_ORB_PALETTE,
|
||||
restColor = "#ffffff",
|
||||
colored = true,
|
||||
restShape = "triangle",
|
||||
dotCount = 21,
|
||||
orbitCount = 3,
|
||||
particlesPerOrbit = 3,
|
||||
@@ -824,7 +823,7 @@ function AgentOrb({
|
||||
() => buildDotSpecs(effDotCount, orbitCount, effParticles),
|
||||
[effDotCount, orbitCount, effParticles]
|
||||
);
|
||||
const restPoints = useMemo(() => triangleOutline(effDotCount), [restShape, effDotCount]);
|
||||
const restPoints = useMemo(() => triangleOutline(effDotCount), [effDotCount]);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = active;
|
||||
@@ -846,7 +845,7 @@ function AgentOrb({
|
||||
restPoints,
|
||||
dotSpecs,
|
||||
orbitGeoms,
|
||||
paletteRgb: colors.map(hexToRgb),
|
||||
paletteRgb: colorsKey.split(",").map(hexToRgb),
|
||||
restRgb: hexToRgb(restColor),
|
||||
colored,
|
||||
radiusScale,
|
||||
@@ -1129,7 +1128,7 @@ function AgentLogoMorph({
|
||||
outline: logoOutlinePoints(dotCount),
|
||||
dotSpecs: buildDotSpecs(dotCount, orbitCount, particlesPerOrbit),
|
||||
orbitGeoms: buildOrbitGeoms(orbitCount),
|
||||
paletteRgb: colors.map(hexToRgb),
|
||||
paletteRgb: colorsKey.split(",").map(hexToRgb),
|
||||
logoRgb: hexToRgb(logoColor),
|
||||
};
|
||||
|
||||
|
||||
@@ -149,10 +149,13 @@ const statuses = allTaskRunStatuses.map((status) => ({
|
||||
function Statuses({ trigger, clearSearchValue, shortcut, searchValue, setFilterType }: MenuProps) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = useCallback((values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ status: values });
|
||||
}, []);
|
||||
const handleChange = useCallback(
|
||||
(values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ status: values });
|
||||
},
|
||||
[clearSearchValue, replace]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return statuses.filter((item) => item.title.toLowerCase().includes(searchValue.toLowerCase()));
|
||||
@@ -205,10 +208,13 @@ function Environments({
|
||||
}: MenuProps) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = useCallback((values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ environment: values });
|
||||
}, []);
|
||||
const handleChange = useCallback(
|
||||
(values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ environment: values });
|
||||
},
|
||||
[clearSearchValue, replace]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return environments.filter((item) =>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { CircleStackIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, useNavigate } from "@remix-run/react";
|
||||
import { useCallback } from "react";
|
||||
import { LogoIcon } from "~/components/LogoIcon";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import {
|
||||
@@ -141,13 +140,13 @@ function Statuses() {
|
||||
const location = useOptimisticLocation();
|
||||
const search = new URLSearchParams(location.search);
|
||||
|
||||
const handleChange = useCallback((values: string[]) => {
|
||||
const handleChange = (values: string[]) => {
|
||||
search.delete("status");
|
||||
for (const value of values) {
|
||||
search.append("status", value);
|
||||
}
|
||||
navigate(`${location.pathname}?${search.toString()}`, { replace: true });
|
||||
}, []);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
|
||||
@@ -16,6 +16,17 @@ import type { UseApiClientOptions } from "./useApiClient.js";
|
||||
import { useApiClient } from "./useApiClient.js";
|
||||
import { createThrottledQueue } from "../utils/throttle.js";
|
||||
|
||||
// Keep subscription lifecycles controlled by their effects while using the latest request inputs.
|
||||
function useStableRequestCallback(callback: () => Promise<void>) {
|
||||
const callbackRef = useRef(callback);
|
||||
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
return useCallback(() => callbackRef.current(), []);
|
||||
}
|
||||
|
||||
export type UseRealtimeRunOptions = UseApiClientOptions & {
|
||||
id?: string;
|
||||
enabled?: boolean;
|
||||
@@ -111,6 +122,9 @@ export function useRealtimeRun<TTask extends AnyTask>(
|
||||
}, []);
|
||||
|
||||
const apiClient = useApiClient(options);
|
||||
const onComplete = options?.onComplete;
|
||||
const skipColumns = options?.skipColumns;
|
||||
const stopOnCompletion = options?.stopOnCompletion;
|
||||
|
||||
const triggerRequest = useCallback(async () => {
|
||||
try {
|
||||
@@ -123,12 +137,12 @@ export function useRealtimeRun<TTask extends AnyTask>(
|
||||
|
||||
await processRealtimeRun(
|
||||
runId,
|
||||
{ skipColumns: options?.skipColumns },
|
||||
{ skipColumns },
|
||||
apiClient,
|
||||
mutateRun,
|
||||
setError,
|
||||
abortControllerRef,
|
||||
typeof options?.stopOnCompletion === "boolean" ? options.stopOnCompletion : true
|
||||
typeof stopOnCompletion === "boolean" ? stopOnCompletion : true
|
||||
);
|
||||
} catch (err) {
|
||||
// Ignore abort errors as they are expected.
|
||||
@@ -146,7 +160,8 @@ export function useRealtimeRun<TTask extends AnyTask>(
|
||||
// Mark the subscription as complete
|
||||
setIsComplete(true);
|
||||
}
|
||||
}, [runId, mutateRun, abortControllerRef, apiClient, setError]);
|
||||
}, [runId, apiClient, mutateRun, setError, setIsComplete, skipColumns, stopOnCompletion]);
|
||||
const requestSubscription = useStableRequestCallback(triggerRequest);
|
||||
|
||||
const hasCalledOnCompleteRef = useRef(false);
|
||||
|
||||
@@ -154,11 +169,11 @@ export function useRealtimeRun<TTask extends AnyTask>(
|
||||
// Only call onComplete when the run has actually finished (has finishedAt),
|
||||
// not just when the subscription stream ends (which can happen due to network issues)
|
||||
useEffect(() => {
|
||||
if (isComplete && run?.finishedAt && options?.onComplete && !hasCalledOnCompleteRef.current) {
|
||||
options.onComplete(run, error);
|
||||
if (isComplete && run?.finishedAt && onComplete && !hasCalledOnCompleteRef.current) {
|
||||
onComplete(run, error);
|
||||
hasCalledOnCompleteRef.current = true;
|
||||
}
|
||||
}, [isComplete, run, error, options?.onComplete]);
|
||||
}, [isComplete, run, error, onComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof options?.enabled === "boolean" && !options.enabled) {
|
||||
@@ -169,18 +184,18 @@ export function useRealtimeRun<TTask extends AnyTask>(
|
||||
return;
|
||||
}
|
||||
|
||||
triggerRequest().finally(() => {});
|
||||
requestSubscription().finally(() => {});
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [runId, stop, options?.enabled]);
|
||||
}, [runId, stop, options?.enabled, requestSubscription]);
|
||||
|
||||
useEffect(() => {
|
||||
if (run?.finishedAt) {
|
||||
setIsComplete(true);
|
||||
}
|
||||
}, [run]);
|
||||
}, [run, setIsComplete]);
|
||||
|
||||
return { run, error, stop };
|
||||
}
|
||||
@@ -274,6 +289,10 @@ export function useRealtimeRunWithStreams<
|
||||
}, []);
|
||||
|
||||
const apiClient = useApiClient(options);
|
||||
const onComplete = options?.onComplete;
|
||||
const skipColumns = options?.skipColumns;
|
||||
const stopOnCompletion = options?.stopOnCompletion;
|
||||
const throttleInMs = options?.throttleInMs;
|
||||
|
||||
const triggerRequest = useCallback(async () => {
|
||||
try {
|
||||
@@ -286,15 +305,15 @@ export function useRealtimeRunWithStreams<
|
||||
|
||||
await processRealtimeRunWithStreams(
|
||||
runId,
|
||||
{ skipColumns: options?.skipColumns },
|
||||
{ skipColumns },
|
||||
apiClient,
|
||||
mutateRun,
|
||||
mutateStreams,
|
||||
streamsRef,
|
||||
setError,
|
||||
abortControllerRef,
|
||||
typeof options?.stopOnCompletion === "boolean" ? options.stopOnCompletion : true,
|
||||
options?.throttleInMs ?? 16
|
||||
typeof stopOnCompletion === "boolean" ? stopOnCompletion : true,
|
||||
throttleInMs ?? 16
|
||||
);
|
||||
} catch (err) {
|
||||
// Ignore abort errors as they are expected.
|
||||
@@ -312,7 +331,18 @@ export function useRealtimeRunWithStreams<
|
||||
// Mark the subscription as complete
|
||||
setIsComplete(true);
|
||||
}
|
||||
}, [runId, mutateRun, mutateStreams, streamsRef, abortControllerRef, apiClient, setError]);
|
||||
}, [
|
||||
runId,
|
||||
apiClient,
|
||||
mutateRun,
|
||||
mutateStreams,
|
||||
setError,
|
||||
setIsComplete,
|
||||
skipColumns,
|
||||
stopOnCompletion,
|
||||
throttleInMs,
|
||||
]);
|
||||
const requestSubscription = useStableRequestCallback(triggerRequest);
|
||||
|
||||
const hasCalledOnCompleteRef = useRef(false);
|
||||
|
||||
@@ -320,11 +350,11 @@ export function useRealtimeRunWithStreams<
|
||||
// Only call onComplete when the run has actually finished (has finishedAt),
|
||||
// not just when the subscription stream ends (which can happen due to network issues)
|
||||
useEffect(() => {
|
||||
if (isComplete && run?.finishedAt && options?.onComplete && !hasCalledOnCompleteRef.current) {
|
||||
options.onComplete(run, error);
|
||||
if (isComplete && run?.finishedAt && onComplete && !hasCalledOnCompleteRef.current) {
|
||||
onComplete(run, error);
|
||||
hasCalledOnCompleteRef.current = true;
|
||||
}
|
||||
}, [isComplete, run, error, options?.onComplete]);
|
||||
}, [isComplete, run, error, onComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof options?.enabled === "boolean" && !options.enabled) {
|
||||
@@ -335,18 +365,18 @@ export function useRealtimeRunWithStreams<
|
||||
return;
|
||||
}
|
||||
|
||||
triggerRequest().finally(() => {});
|
||||
requestSubscription().finally(() => {});
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [runId, stop, options?.enabled]);
|
||||
}, [runId, stop, options?.enabled, requestSubscription]);
|
||||
|
||||
useEffect(() => {
|
||||
if (run?.finishedAt) {
|
||||
setIsComplete(true);
|
||||
}
|
||||
}, [run]);
|
||||
}, [run, setIsComplete]);
|
||||
|
||||
return { run, streams: streams ?? initialStreamsFallback, error, stop };
|
||||
}
|
||||
@@ -442,6 +472,8 @@ export function useRealtimeRunsWithTag<TTask extends AnyTask>(
|
||||
}, []);
|
||||
|
||||
const apiClient = useApiClient(options);
|
||||
const createdAt = options?.createdAt;
|
||||
const skipColumns = options?.skipColumns;
|
||||
|
||||
const triggerRequest = useCallback(async () => {
|
||||
try {
|
||||
@@ -454,7 +486,7 @@ export function useRealtimeRunsWithTag<TTask extends AnyTask>(
|
||||
|
||||
await processRealtimeRunsWithTag(
|
||||
tag,
|
||||
{ createdAt: options?.createdAt, skipColumns: options?.skipColumns },
|
||||
{ createdAt, skipColumns },
|
||||
apiClient,
|
||||
mutateRuns,
|
||||
runsRef,
|
||||
@@ -474,19 +506,20 @@ export function useRealtimeRunsWithTag<TTask extends AnyTask>(
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [normalizedTag, mutateRuns, runsRef, abortControllerRef, apiClient, setError]);
|
||||
}, [tag, createdAt, skipColumns, apiClient, mutateRuns, setError]);
|
||||
const requestSubscription = useStableRequestCallback(triggerRequest);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof options?.enabled === "boolean" && !options.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
triggerRequest().finally(() => {});
|
||||
requestSubscription().finally(() => {});
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [normalizedTag, stop, options?.enabled]);
|
||||
}, [normalizedTag, stop, options?.enabled, requestSubscription]);
|
||||
|
||||
return { runs: runs ?? [], error, stop };
|
||||
}
|
||||
@@ -572,18 +605,19 @@ export function useRealtimeBatch<TTask extends AnyTask>(
|
||||
}
|
||||
}
|
||||
}, [batchId, mutateRuns, runsRef, abortControllerRef, apiClient, setError]);
|
||||
const requestSubscription = useStableRequestCallback(triggerRequest);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof options?.enabled === "boolean" && !options.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
triggerRequest().finally(() => {});
|
||||
requestSubscription().finally(() => {});
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [batchId, stop, options?.enabled]);
|
||||
}, [batchId, stop, options?.enabled, requestSubscription]);
|
||||
|
||||
return { runs: runs ?? [], error, stop };
|
||||
}
|
||||
@@ -821,16 +855,20 @@ function useRealtimeStreamImplementation<TPart>(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onDataCallback = options?.onData;
|
||||
const onData = useCallback(
|
||||
(data: TPart) => {
|
||||
if (options?.onData) {
|
||||
options.onData(data);
|
||||
if (onDataCallback) {
|
||||
onDataCallback(data);
|
||||
}
|
||||
},
|
||||
[options?.onData]
|
||||
[onDataCallback]
|
||||
);
|
||||
|
||||
const apiClient = useApiClient(options);
|
||||
const timeoutInSeconds = options?.timeoutInSeconds;
|
||||
const startIndex = options?.startIndex;
|
||||
const throttleInMs = options?.throttleInMs;
|
||||
|
||||
const triggerRequest = useCallback(async () => {
|
||||
try {
|
||||
@@ -850,9 +888,9 @@ function useRealtimeStreamImplementation<TPart>(
|
||||
setError,
|
||||
onData,
|
||||
abortControllerRef,
|
||||
options?.timeoutInSeconds,
|
||||
options?.startIndex,
|
||||
options?.throttleInMs ?? 16
|
||||
timeoutInSeconds,
|
||||
startIndex,
|
||||
throttleInMs ?? 16
|
||||
);
|
||||
} catch (err) {
|
||||
// Ignore abort errors as they are expected.
|
||||
@@ -870,7 +908,19 @@ function useRealtimeStreamImplementation<TPart>(
|
||||
// Mark the subscription as complete
|
||||
setIsComplete(true);
|
||||
}
|
||||
}, [runId, streamKey, mutateParts, partsRef, abortControllerRef, apiClient, setError]);
|
||||
}, [
|
||||
runId,
|
||||
streamKey,
|
||||
apiClient,
|
||||
mutateParts,
|
||||
setError,
|
||||
setIsComplete,
|
||||
onData,
|
||||
timeoutInSeconds,
|
||||
startIndex,
|
||||
throttleInMs,
|
||||
]);
|
||||
const requestSubscription = useStableRequestCallback(triggerRequest);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof options?.enabled === "boolean" && !options.enabled) {
|
||||
@@ -881,12 +931,12 @@ function useRealtimeStreamImplementation<TPart>(
|
||||
return;
|
||||
}
|
||||
|
||||
triggerRequest().finally(() => {});
|
||||
requestSubscription().finally(() => {});
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [runId, stop, options?.enabled]);
|
||||
}, [runId, stop, options?.enabled, requestSubscription]);
|
||||
|
||||
return { parts: parts ?? initialPartsFallback, error, stop };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user