Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"typescript/consistent-type-imports": "error",
"import/no-duplicates": "error",
"import/namespace": "off",
"react/exhaustive-deps": "off",
"react/exhaustive-deps": "error",
Comment thread
carderne marked this conversation as resolved.
"react/rules-of-hooks": "off",
"guard-for-in": "error",
"symbol-description": "error",
Expand Down
11 changes: 7 additions & 4 deletions apps/webapp/app/assets/icons/AnimatedHourglassIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useAnimate } from "framer-motion";
import { HourglassIcon } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useRef } from "react";

export function AnimatedHourglassIcon({
className,
Expand All @@ -10,18 +10,21 @@ export function AnimatedHourglassIcon({
delay?: number;
}) {
const [scope, animate] = useAnimate();
const initialDelay = useRef(delay);

useEffect(() => {
animate(
const controls = animate(
[
[scope.current, { rotate: 0 }, { duration: 0.7 }],
[scope.current, { rotate: 180 }, { duration: 0.3 }],
[scope.current, { rotate: 180 }, { duration: 0.7 }],
[scope.current, { rotate: 360 }, { duration: 0.3 }],
],
{ repeat: Infinity, delay }
{ repeat: Infinity, delay: initialDelay.current }
);
}, []);

return () => controls.stop();
}, [animate, scope]);
Comment thread
carderne marked this conversation as resolved.

return <HourglassIcon ref={scope} className={className} />;
}
2 changes: 1 addition & 1 deletion apps/webapp/app/components/AskAI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down
2 changes: 1 addition & 1 deletion apps/webapp/app/components/DevPresence.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
}
Expand Down
4 changes: 2 additions & 2 deletions apps/webapp/app/components/Feedback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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
Expand Down
11 changes: 7 additions & 4 deletions apps/webapp/app/components/admin/FeatureFlagsDialog.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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>>({});
Expand All @@ -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) {
Expand All @@ -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);
}
Expand Down
5 changes: 3 additions & 2 deletions apps/webapp/app/components/admin/debugRun.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
Expand Down
68 changes: 34 additions & 34 deletions apps/webapp/app/components/code/AIQueryInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,7 @@ export function DashboardAgentPanel({
watchCard.requestId,
active?.chatId,
actionPath,
organization.id,
claimChatSlot,
loadHistory,
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down
86 changes: 48 additions & 38 deletions apps/webapp/app/components/navigation/NotificationPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions apps/webapp/app/components/navigation/useReorderableList.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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]);

Expand Down
Loading