chore: enforce exhaustive React hook dependencies
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",
|
||||
|
||||
@@ -12,7 +12,7 @@ export function AnimatedHourglassIcon({
|
||||
const [scope, animate] = useAnimate();
|
||||
|
||||
useEffect(() => {
|
||||
animate(
|
||||
const controls = animate(
|
||||
[
|
||||
[scope.current, { rotate: 0 }, { duration: 0.7 }],
|
||||
[scope.current, { rotate: 180 }, { duration: 0.3 }],
|
||||
@@ -21,7 +21,9 @@ export function AnimatedHourglassIcon({
|
||||
],
|
||||
{ repeat: Infinity, delay }
|
||||
);
|
||||
}, []);
|
||||
|
||||
return () => controls.stop();
|
||||
}, [animate, delay, 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
@@ -285,7 +285,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
@@ -190,15 +190,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>
|
||||
|
||||
@@ -41,26 +41,27 @@ const POLL_INTERVAL_MS = 60_000;
|
||||
export function useIncidentStatus() {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const fetcher = useFetcher<typeof loader>();
|
||||
const { load, state } = fetcher;
|
||||
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");
|
||||
}
|
||||
|
||||
// Poll every 60 seconds
|
||||
const interval = setInterval(() => {
|
||||
if (fetcher.state === "idle") {
|
||||
fetcher.load("/resources/incidents");
|
||||
if (state === "idle") {
|
||||
load("/resources/incidents");
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isManagedCloud]);
|
||||
}, [isManagedCloud, load, state]);
|
||||
|
||||
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,25 @@ 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({
|
||||
query: props.query,
|
||||
from: props.from,
|
||||
to: props.to,
|
||||
period: props.period,
|
||||
scope: props.scope,
|
||||
taskIdentifiers: props.taskIdentifiers,
|
||||
queues: props.queues,
|
||||
responseModels: props.responseModels,
|
||||
promptSlugs: props.promptSlugs,
|
||||
promptVersions: props.promptVersions,
|
||||
operations: props.operations,
|
||||
providers: props.providers,
|
||||
});
|
||||
|
||||
// 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">
|
||||
|
||||
@@ -42,6 +42,7 @@ const POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
export function useRecentChangelogs(organizationId?: string, projectId?: string) {
|
||||
const fetcher = useFetcher<typeof loader>();
|
||||
const { load, state } = fetcher;
|
||||
const lastLoadedUrl = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,19 +52,19 @@ export function useRecentChangelogs(organizationId?: string, projectId?: string)
|
||||
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);
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (fetcher.state === "idle") {
|
||||
fetcher.load(url);
|
||||
if (state === "idle") {
|
||||
load(url);
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [organizationId, projectId]);
|
||||
}, [organizationId, projectId, load, state]);
|
||||
|
||||
return {
|
||||
changelogs: fetcher.data?.changelogs ?? [],
|
||||
|
||||
@@ -41,24 +41,25 @@ const POLL_INTERVAL_MS = 60000; // 1 minute
|
||||
|
||||
export function usePlatformNotifications(organizationId: string, projectId: string) {
|
||||
const fetcher = useFetcher<typeof loader>();
|
||||
const { load, state } = fetcher;
|
||||
const lastLoadedUrl = useRef<string | null>(null);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (fetcher.state === "idle") {
|
||||
fetcher.load(url);
|
||||
if (state === "idle") {
|
||||
load(url);
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [organizationId, projectId]);
|
||||
}, [organizationId, projectId, load, state]);
|
||||
|
||||
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