Skip to content
Open
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
13 changes: 3 additions & 10 deletions apps/web/__tests__/ai-categorize-senders.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import {
aiCategorizeSenders,
REQUEST_MORE_INFORMATION_CATEGORY,
} from "@/utils/ai/categorize-sender/ai-categorize-senders";
import { aiCategorizeSenders } from "@/utils/ai/categorize-sender/ai-categorize-senders";
import { defaultCategory } from "@/utils/categories";
import { aiCategorizeSender } from "@/utils/ai/categorize-sender/ai-categorize-single-sender";
import { getEmailAccount } from "@/__tests__/helpers";
Expand Down Expand Up @@ -135,9 +132,7 @@ describe.runIf(isAiTest)("AI Sender Categorization", () => {
});

if (expectedCategory === "Unknown") {
expect([REQUEST_MORE_INFORMATION_CATEGORY, "Unknown"]).toContain(
result?.category,
);
expect(result?.category).toBe("Unknown");
} else {
expect(result?.category).toBe(expectedCategory);
}
Expand All @@ -161,9 +156,7 @@ describe.runIf(isAiTest)("AI Sender Categorization", () => {
categories: getEnabledCategories(),
});

expect([REQUEST_MORE_INFORMATION_CATEGORY, "Unknown"]).toContain(
result?.category,
);
expect(result?.category).toBe("Unknown");
},
TIMEOUT,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"use client";

import { useEffect, useState } from "react";
import { ProgressPanel } from "@/components/ProgressPanel";
import { useBulkOperationProgress } from "@/hooks/useDeepClean";

export function BulkOperationProgress() {
const [hasActiveOperations, setHasActiveOperations] = useState(false);

const { data } = useBulkOperationProgress(
hasActiveOperations ? 2000 : 10_000, // Poll more frequently when operations are active
);

const operations = data?.operations || [];
const activeOperations = operations.filter(
(op) => op.status === "processing" || op.status === "pending",
);

useEffect(() => {
setHasActiveOperations(activeOperations.length > 0);
}, [activeOperations.length]);

// Show progress for each active operation
return (
<>
{operations.map((operation) => {
const isActive =
operation.status === "processing" || operation.status === "pending";
const isCompleted = operation.status === "completed";

if (!isActive && !isCompleted) return null;

// Hide completed operations after 5 seconds
if (isCompleted) {
setTimeout(() => {
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid calling setTimeout within the render path; move this side effect into a useEffect tied to completed operations or remove it if no action is needed.

Prompt for AI agents
Address the following comment on apps/web/app/(app)/[emailAccountId]/deep-clean/BulkOperationProgress.tsx at line 35:

<comment>Avoid calling setTimeout within the render path; move this side effect into a useEffect tied to completed operations or remove it if no action is needed.</comment>

<file context>
@@ -0,0 +1,63 @@
+
+        // Hide completed operations after 5 seconds
+        if (isCompleted) {
+          setTimeout(() =&gt; {
+            // This will be handled by React&#39;s re-render when the operation is removed from Redis
+          }, 5000);
</file context>
Fix with Cubic

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling setTimeout during render enqueues a new timer every render for each completed operation, leaking timers and hurting performance. Move this timer logic into a useEffect tied to completed operations or remove it.

Prompt for AI agents
Address the following comment on apps/web/app/(app)/[emailAccountId]/deep-clean/BulkOperationProgress.tsx at line 35:

<comment>Calling setTimeout during render enqueues a new timer every render for each completed operation, leaking timers and hurting performance. Move this timer logic into a useEffect tied to completed operations or remove it.</comment>

<file context>
@@ -0,0 +1,63 @@
+
+        // Hide completed operations after 5 seconds
+        if (isCompleted) {
+          setTimeout(() =&gt; {
+            // This will be handled by React&#39;s re-render when the operation is removed from Redis
+          }, 5000);
</file context>
Fix with Cubic

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this setTimeout out of render; invoking it here queues a new timeout on every re-render for completed operations, leaking timers and doing unnecessary work. Use a useEffect tied to completion state instead.

Prompt for AI agents
Address the following comment on apps/web/app/(app)/[emailAccountId]/deep-clean/BulkOperationProgress.tsx at line 35:

<comment>Move this setTimeout out of render; invoking it here queues a new timeout on every re-render for completed operations, leaking timers and doing unnecessary work. Use a useEffect tied to completion state instead.</comment>

<file context>
@@ -0,0 +1,63 @@
+
+        // Hide completed operations after 5 seconds
+        if (isCompleted) {
+          setTimeout(() =&gt; {
+            // This will be handled by React&#39;s re-render when the operation is removed from Redis
+          }, 5000);
</file context>
Fix with Cubic

// This will be handled by React's re-render when the operation is removed from Redis
}, 5000);
}
Comment on lines +33 to +38
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remove render-time setTimeout side effect.

Calling setTimeout directly in render spins up a new timer on every render pass, even under the same completion state, leaking timers and causing unnecessary work. Move this logic into an effect or drop it entirely if Redis cleanup already controls visibility.

Apply this diff to eliminate the render-side effect:

-        // Hide completed operations after 5 seconds
-        if (isCompleted) {
-          setTimeout(() => {
-            // This will be handled by React's re-render when the operation is removed from Redis
-          }, 5000);
-        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Hide completed operations after 5 seconds
if (isCompleted) {
setTimeout(() => {
// This will be handled by React's re-render when the operation is removed from Redis
}, 5000);
}
🤖 Prompt for AI Agents
In apps/web/app/(app)/[emailAccountId]/deep-clean/BulkOperationProgress.tsx
around lines 33-38, remove the render-time setTimeout call (which creates a new
timer on every render) and instead move that logic into a useEffect that watches
isCompleted: when isCompleted transitions to true start a single timeout to
hide/remove the operation after 5s and return a cleanup function to clear the
timeout if isCompleted changes or the component unmounts; alternatively, if
Redis-driven cleanup already controls visibility, delete the timeout logic
entirely.


const displayName =
operation.operationType === "archive"
? `Archiving ${operation.categoryOrSender}`
: `Marking ${operation.categoryOrSender} as read`;

const completedText =
operation.operationType === "archive"
? `Archived ${operation.completedItems} emails!`
: `Marked ${operation.completedItems} emails as read!`;

return (
<ProgressPanel
key={operation.operationId}
totalItems={operation.totalItems}
remainingItems={operation.totalItems - operation.completedItems}
inProgressText={displayName}
completedText={completedText}
itemLabel="emails"
/>
);
})}
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"use client";

import { useEffect, useState } from "react";
import { atom, useAtom } from "jotai";
import useSWR from "swr";
import { ProgressPanel } from "@/components/ProgressPanel";
import type { CategorizeProgress } from "@/app/api/user/categorize/senders/progress/route";
import { useInterval } from "@/hooks/useInterval";

const isCategorizeInProgressAtom = atom(false);

export function useCategorizeProgress() {
const [isBulkCategorizing, setIsBulkCategorizing] = useAtom(
isCategorizeInProgressAtom,
);
return { isBulkCategorizing, setIsBulkCategorizing };
}

export function CategorizeSendersProgress({
refresh = false,
}: {
refresh: boolean;
}) {
const { isBulkCategorizing } = useCategorizeProgress();
const [fakeProgress, setFakeProgress] = useState(0);

const { data } = useSWR<CategorizeProgress>(
"/api/user/categorize/senders/progress",
{
refreshInterval: refresh || isBulkCategorizing ? 1000 : undefined,
},
);

useInterval(
() => {
if (!data?.totalItems) return;

setFakeProgress((prev) => {
const realCompleted = data.completedItems || 0;
if (realCompleted > prev) return realCompleted;

const maxProgress = Math.min(
Math.floor(data.totalItems * 0.9),
realCompleted + 30,
);
return prev < maxProgress ? prev + 1 : prev;
});
},
isBulkCategorizing ? 1500 : null,
);

const { setIsBulkCategorizing } = useCategorizeProgress();
useEffect(() => {
let timeoutId: NodeJS.Timeout | undefined;
if (data?.completedItems === data?.totalItems) {
timeoutId = setTimeout(() => {
setIsBulkCategorizing(false);
setFakeProgress(0);
}, 3000);
}
return () => {
if (timeoutId) clearTimeout(timeoutId);
};
}, [data?.completedItems, data?.totalItems, setIsBulkCategorizing]);

if (!data) return null;

const totalItems = data.totalItems || 0;
const displayedProgress = Math.max(data.completedItems || 0, fakeProgress);

return (
<ProgressPanel
totalItems={totalItems}
remainingItems={totalItems - displayedProgress}
inProgressText="Categorizing senders..."
completedText={`Categorization complete! ${displayedProgress} categorized!`}
itemLabel="senders"
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"use client";

import { useState } from "react";
import { SparklesIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { bulkCategorizeSendersAction } from "@/utils/actions/categorize";
import { PremiumTooltip, usePremium } from "@/components/PremiumAlert";
import { usePremiumModal } from "@/app/(app)/premium/PremiumModal";
import type { ButtonProps } from "@/components/ui/button";
import { useCategorizeProgress } from "@/app/(app)/[emailAccountId]/smart-categories/CategorizeProgress";
import { Tooltip } from "@/components/Tooltip";
import { useAccount } from "@/providers/EmailAccountProvider";

export function CategorizeWithAiButton({
buttonProps,
}: {
buttonProps?: ButtonProps;
}) {
const { emailAccountId } = useAccount();
const [isCategorizing, setIsCategorizing] = useState(false);
const { hasAiAccess } = usePremium();
const { PremiumModal, openModal: openPremiumModal } = usePremiumModal();

const { setIsBulkCategorizing } = useCategorizeProgress();

return (
<>
<CategorizeWithAiButtonTooltip
hasAiAccess={hasAiAccess}
openPremiumModal={openPremiumModal}
>
<Button
type="button"
loading={isCategorizing}
disabled={!hasAiAccess}
onClick={async () => {
if (isCategorizing) return;
toast.promise(
async () => {
setIsCategorizing(true);
setIsBulkCategorizing(true);
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If bulkCategorizeSendersAction fails, setIsBulkCategorizing(true) is never reverted, leaving the progress indicator stuck on and blocking retries.

Prompt for AI agents
Address the following comment on apps/web/app/(app)/[emailAccountId]/deep-clean/CategorizeWithAiButton.tsx at line 42:

<comment>If `bulkCategorizeSendersAction` fails, `setIsBulkCategorizing(true)` is never reverted, leaving the progress indicator stuck on and blocking retries.</comment>

<file context>
@@ -0,0 +1,105 @@
+            toast.promise(
+              async () =&gt; {
+                setIsCategorizing(true);
+                setIsBulkCategorizing(true);
+                const result =
+                  await bulkCategorizeSendersAction(emailAccountId);
</file context>
Fix with Cubic

const result =
await bulkCategorizeSendersAction(emailAccountId);

if (result?.serverError) {
setIsCategorizing(false);
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the categorize action fails we keep isBulkCategorizing stuck at true, so the Deep Clean progress UI never stops. Reset the bulk categorizing flag before throwing to avoid a permanently spinning progress indicator.

Prompt for AI agents
Address the following comment on apps/web/app/(app)/[emailAccountId]/deep-clean/CategorizeWithAiButton.tsx at line 47:

<comment>If the categorize action fails we keep isBulkCategorizing stuck at true, so the Deep Clean progress UI never stops. Reset the bulk categorizing flag before throwing to avoid a permanently spinning progress indicator.</comment>

<file context>
@@ -0,0 +1,105 @@
+                  await bulkCategorizeSendersAction(emailAccountId);
+
+                if (result?.serverError) {
+                  setIsCategorizing(false);
+                  throw new Error(result.serverError);
+                }
</file context>
Fix with Cubic

throw new Error(result.serverError);
}

setIsCategorizing(false);

return result?.data?.totalUncategorizedSenders || 0;
},
{
loading: "Categorizing senders... This might take a while.",
success: (totalUncategorizedSenders) => {
return totalUncategorizedSenders
? `Categorizing ${totalUncategorizedSenders} senders...`
: "There are no more senders to categorize.";
},
error: (err) => {
return `Error categorizing senders: ${err.message}`;
},
},
);
Comment on lines +37 to +66
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reset categorization flags when the action fails

We set setIsBulkCategorizing(true) and flip isCategorizing before the request, but we never reset them when the action fails or throws (network error, serverError branch). That leaves the button stuck in a loading/“in progress” state and the progress UI permanently true. Use a try/finally to always clear isCategorizing, and reset the bulk flag on failure before rethrowing.

-            toast.promise(
-              async () => {
-                setIsCategorizing(true);
-                setIsBulkCategorizing(true);
-                const result =
-                  await bulkCategorizeSendersAction(emailAccountId);
-
-                if (result?.serverError) {
-                  setIsCategorizing(false);
-                  throw new Error(result.serverError);
-                }
-
-                setIsCategorizing(false);
-
-                return result?.data?.totalUncategorizedSenders || 0;
-              },
+            toast.promise(
+              async () => {
+                setIsCategorizing(true);
+                setIsBulkCategorizing(true);
+                try {
+                  const result =
+                    await bulkCategorizeSendersAction(emailAccountId);
+
+                  if (result?.serverError) {
+                    throw new Error(result.serverError);
+                  }
+
+                  return result?.data?.totalUncategorizedSenders || 0;
+                } catch (error) {
+                  setIsBulkCategorizing(false);
+                  throw error;
+                } finally {
+                  setIsCategorizing(false);
+                }
+              },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onClick={async () => {
if (isCategorizing) return;
toast.promise(
async () => {
setIsCategorizing(true);
setIsBulkCategorizing(true);
const result =
await bulkCategorizeSendersAction(emailAccountId);
if (result?.serverError) {
setIsCategorizing(false);
throw new Error(result.serverError);
}
setIsCategorizing(false);
return result?.data?.totalUncategorizedSenders || 0;
},
{
loading: "Categorizing senders... This might take a while.",
success: (totalUncategorizedSenders) => {
return totalUncategorizedSenders
? `Categorizing ${totalUncategorizedSenders} senders...`
: "There are no more senders to categorize.";
},
error: (err) => {
return `Error categorizing senders: ${err.message}`;
},
},
);
onClick={async () => {
if (isCategorizing) return;
toast.promise(
async () => {
setIsCategorizing(true);
setIsBulkCategorizing(true);
try {
const result =
await bulkCategorizeSendersAction(emailAccountId);
if (result?.serverError) {
throw new Error(result.serverError);
}
return result?.data?.totalUncategorizedSenders || 0;
} catch (error) {
setIsBulkCategorizing(false);
throw error;
} finally {
setIsCategorizing(false);
}
},
{
loading: "Categorizing senders... This might take a while.",
success: (totalUncategorizedSenders) => {
return totalUncategorizedSenders
? `Categorizing ${totalUncategorizedSenders} senders...`
: "There are no more senders to categorize.";
},
error: (err) => {
return `Error categorizing senders: ${err.message}`;
},
},
);
🤖 Prompt for AI Agents
In apps/web/app/(app)/[emailAccountId]/deep-clean/CategorizeWithAiButton.tsx
around lines 37-66, wrap the async action in a try/finally (with an optional
catch) so the state flags are always cleared: set both setIsCategorizing(false)
and setIsBulkCategorizing(false) in a finally block to ensure the button and
progress UI are reset after success or failure; if you need to preserve
different behavior on failure, clear setIsBulkCategorizing(false) in the catch
before rethrowing the error and still clear setIsCategorizing(false) in finally.

}}
{...buttonProps}
>
{buttonProps?.children || (
<>
<SparklesIcon className="mr-2 size-4" />
Categorize Senders with AI
</>
)}
</Button>
</CategorizeWithAiButtonTooltip>
<PremiumModal />
</>
);
}

function CategorizeWithAiButtonTooltip({
children,
hasAiAccess,
openPremiumModal,
}: {
children: React.ReactElement<any>;
hasAiAccess: boolean;
openPremiumModal: () => void;
}) {
if (hasAiAccess) {
return (
<Tooltip content="Categorize thousands of senders. This will take a few minutes.">
{children}
</Tooltip>
);
}

return (
<PremiumTooltip showTooltip={!hasAiAccess} openModal={openPremiumModal}>
{children}
</PremiumTooltip>
);
}
Loading
Loading