Skip to content
Closed
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,7 @@ docker-compose.override.yml
# cli logs
logs

coverage
coverage

# Memory bank (Cline AI documentation)
memory-bank/
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import { TypographyH3 } from "@/components/Typography";
import { useStep } from "@/app/(app)/[emailAccountId]/clean/useStep";
import { ButtonListSurvey } from "@/components/ButtonListSurvey";
import { CleanAction } from "@prisma/client";
import { Button } from "@/components/ui/button";

export function ActionSelectionStep() {
const { onNext } = useStep();
const { onNext, onPrevious } = useStep();
const [_, setAction] = useQueryState(
"action",
parseAsStringEnum([CleanAction.ARCHIVE, CleanAction.MARK_READ]),
Expand Down Expand Up @@ -39,6 +40,12 @@ export function ActionSelectionStep() {
]}
onClick={(value) => onSetAction(value as CleanAction)}
/>

<div className="mt-6">
<Button variant="outline" onClick={onPrevious}>
Back
</Button>
</div>
Comment on lines +44 to +48
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add explicit button type

Match other steps and prevent accidental submits inside forms by setting type="button".

Apply:

-      <div className="mt-6">
-        <Button variant="outline" onClick={onPrevious}>
+      <div className="mt-6">
+        <Button type="button" variant="outline" onClick={onPrevious}>
           Back
         </Button>
       </div>

As per coding guidelines.

📝 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
<div className="mt-6">
<Button variant="outline" onClick={onPrevious}>
Back
</Button>
</div>
<div className="mt-6">
<Button type="button" variant="outline" onClick={onPrevious}>
Back
</Button>
</div>
🤖 Prompt for AI Agents
In apps/web/app/(app)/[emailAccountId]/clean/ActionSelectionStep.tsx around
lines 44 to 48, the Back button lacks an explicit type which can cause
accidental form submission; set the Button's type attribute to "button" (e.g.,
add type="button") so it matches other steps and prevents implicit submit
behavior inside forms.

</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const schema = z.object({ instructions: z.string().optional() });
type Inputs = z.infer<typeof schema>;

export function CleanInstructionsStep() {
const { onNext } = useStep();
const { onNext, onPrevious } = useStep();
const {
register,
handleSubmit,
Expand Down Expand Up @@ -51,7 +51,7 @@ export function CleanInstructionsStep() {
name="starred"
enabled={skipStates.skipStarred}
onChange={(value) => setSkipStates({ skipStarred: value })}
labelRight="Starred emails"
labelRight="Starred/Flagged emails"
/>
<Toggle
name="calendar"
Expand Down Expand Up @@ -103,7 +103,10 @@ I'm in the middle of a building project, keep those emails too.`}
</div>
)}

<div className="mt-6 flex justify-center">
<div className="mt-6 flex justify-center gap-2">
<Button type="button" variant="outline" onClick={onPrevious}>
Back
</Button>
<Button type="submit">Continue</Button>
</div>
</form>
Expand Down
19 changes: 14 additions & 5 deletions apps/web/app/(app)/[emailAccountId]/clean/ConfirmationStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { PREVIEW_RUN_COUNT } from "@/app/(app)/[emailAccountId]/clean/consts";
import { HistoryIcon, SettingsIcon } from "lucide-react";
import { useAccount } from "@/providers/EmailAccountProvider";
import { prefixPath } from "@/utils/path";
import { isGoogleProvider } from "@/utils/email/provider-types";
import { useStep } from "@/app/(app)/[emailAccountId]/clean/useStep";

export function ConfirmationStep({
showFooter,
Expand All @@ -36,7 +38,9 @@ export function ConfirmationStep({
reuseSettings: boolean;
}) {
const router = useRouter();
const { emailAccountId } = useAccount();
const { emailAccountId, emailAccount } = useAccount();
const { onPrevious } = useStep();
const isGmail = isGoogleProvider(emailAccount?.provider);
Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

emailAccount?.provider is always undefined because the provider lives under emailAccount.account.provider, so Gmail accounts will fall through to the Outlook messaging. Use the nested provider value (or the provider field returned by useAccount) when calling isGoogleProvider.

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

<comment>`emailAccount?.provider` is always undefined because the provider lives under `emailAccount.account.provider`, so Gmail accounts will fall through to the Outlook messaging. Use the nested provider value (or the provider field returned by `useAccount`) when calling `isGoogleProvider`.</comment>

<file context>
@@ -36,7 +38,9 @@ export function ConfirmationStep({
-  const { emailAccountId } = useAccount();
+  const { emailAccountId, emailAccount } = useAccount();
+  const { onPrevious } = useStep();
+  const isGmail = isGoogleProvider(emailAccount?.provider);
 
   const handleStartCleaning = async () =&gt; {
</file context>
Suggested change
const isGmail = isGoogleProvider(emailAccount?.provider);
const isGmail = isGoogleProvider(emailAccount?.account?.provider);
Fix with Cubic


const handleStartCleaning = async () => {
const result = await cleanInboxAction(emailAccountId, {
Expand Down Expand Up @@ -89,13 +93,15 @@ export function ConfirmationStep({
<li>
{action === CleanAction.ARCHIVE ? (
<>
Archived emails will be labeled{" "}
<Badge color="green">Archived</Badge> in Gmail.
Archived emails will be {isGmail ? "labeled" : "moved to the"}{" "}
<Badge color="green">Archive{isGmail ? "d" : ""}</Badge>{" "}
{isGmail ? "in Gmail" : "folder in Outlook"}.
</>
) : (
<>
Emails marked as read will be labeled{" "}
<Badge color="green">Read</Badge> in Gmail.
<Badge color="green">Read</Badge>{" "}
{isGmail ? "in Gmail" : "in Outlook"}.
</>
)}
</li>
Expand All @@ -115,7 +121,10 @@ export function ConfirmationStep({
)}
</ul>

<div className="mt-6">
<div className="mt-6 flex justify-center gap-2">
<Button size="lg" variant="outline" onClick={onPrevious}>
Back
</Button>
Comment on lines +124 to +127
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Prevent duplicate submissions; gate Gmail‑only settings link; add button type

  • Starting cleaning can be clicked repeatedly; add a loading/disabled state to avoid double job creation.
  • The “Edit settings” link points to a Gmail‑only page; hide or alter for non‑Google providers to avoid an error page.
  • Add type="button" on Back.

Apply:

@@
-export function ConfirmationStep({
+export function ConfirmationStep({
@@
-  const router = useRouter();
+  const router = useRouter();
+  const [isLoading, setIsLoading] = useState(false);
@@
-  const handleStartCleaning = async () => {
-    const result = await cleanInboxAction(emailAccountId, {
+  const handleStartCleaning = async () => {
+    setIsLoading(true);
+    const result = await cleanInboxAction(emailAccountId, {
       daysOld: timeRange ?? 7,
       instructions: instructions || "",
       action: action || CleanAction.ARCHIVE,
       maxEmails: PREVIEW_RUN_COUNT,
       skips,
     });
 
     if (result?.serverError) {
       toastError({ description: result.serverError });
-      return;
+      setIsLoading(false);
+      return;
     }
 
     router.push(
       prefixPath(
         emailAccountId,
         `/clean/run?jobId=${result?.data?.jobId}&isPreviewBatch=true`,
       ),
     );
+    setIsLoading(false);
   };
@@
-      <div className="mt-6 flex justify-center gap-2">
-        <Button size="lg" variant="outline" onClick={onPrevious}>
+      <div className="mt-6 flex justify-center gap-2">
+        <Button type="button" size="lg" variant="outline" onClick={onPrevious}>
           Back
         </Button>
-        <Button size="lg" onClick={handleStartCleaning}>
+        <Button
+          size="lg"
+          onClick={handleStartCleaning}
+          disabled={isLoading}
+          aria-busy={isLoading}
+        >
           Start Cleaning
         </Button>
       </div>
@@
-          <FooterLink
-            icon={SettingsIcon}
-            text="Edit settings"
-            href={prefixPath(emailAccountId, "/clean/onboarding")}
-          />
+          {isGmail && (
+            <FooterLink
+              icon={SettingsIcon}
+              text="Edit settings"
+              href={prefixPath(emailAccountId, "/clean/onboarding")}
+            />
+          )}

Add missing import:

-import Link from "next/link";
+import Link from "next/link";
+import { useState } from "react";

Optionally, consider provider‑specific copy: Gmail uses “All Mail” rather than an “Archive” folder; you may want to keep the badge label constant (“Archive”) for both or switch Gmail text to “All Mail”.

Based on learnings.

Also applies to: 133-147

🤖 Prompt for AI Agents
In apps/web/app/(app)/[emailAccountId]/clean/ConfirmationStep.tsx around lines
124-127 (and similarly 133-147), prevent duplicate submissions by adding a local
loading state (import useState from React) and set loading=true when starting
the cleaning job, disable the primary Start/Confirm button while loading and
show a spinner or loading label, and ensure the Back button has type="button";
additionally gate the “Edit settings” link so it only renders or navigates for
Google/Gmail providers (for others either hide it or render a non-navigating
tooltip/disabled link), and add the missing import for useState (or whatever
hook you use) at the top of the file.

<Button size="lg" onClick={handleStartCleaning}>
Start Cleaning
</Button>
Expand Down
201 changes: 201 additions & 0 deletions apps/web/app/(app)/[emailAccountId]/clean/PreviewStep.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
"use client";

import { useCallback, useEffect, useState, useMemo } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { CleanAction } from "@prisma/client";
import { LoadingContent } from "@/components/LoadingContent";
import { EmailFirehose } from "@/app/(app)/[emailAccountId]/clean/EmailFirehose";
import { cleanInboxAction } from "@/utils/actions/clean";
import { PREVIEW_RUN_COUNT } from "@/app/(app)/[emailAccountId]/clean/consts";
import { useAccount } from "@/providers/EmailAccountProvider";
import { toastError } from "@/components/Toast";
import { Button } from "@/components/ui/button";
import {
CardGreen,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useEmailStream } from "@/app/(app)/[emailAccountId]/clean/useEmailStream";

export function PreviewStep() {
const router = useRouter();
const { emailAccountId } = useAccount();
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = useState(true);
const [jobId, setJobId] = useState<string | null>(null);
const [error, setError] = useState<string | undefined>(undefined);
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
const [isLoadingFull, setIsLoadingFull] = useState(false);

const action =
(searchParams.get("action") as CleanAction) ?? CleanAction.ARCHIVE;
const timeRange = searchParams.get("timeRange")
? Number.parseInt(searchParams.get("timeRange")!)
: 7;
const instructions = searchParams.get("instructions") ?? undefined;
const skipReply = searchParams.get("skipReply") === "true";
const skipStarred = searchParams.get("skipStarred") === "true";
const skipCalendar = searchParams.get("skipCalendar") === "true";
const skipReceipt = searchParams.get("skipReceipt") === "true";
const skipAttachment = searchParams.get("skipAttachment") === "true";

const runPreview = useCallback(async () => {
setIsLoading(true);
setError(null);
Comment on lines +44 to +46
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix type inconsistency.

Line 46 calls setError(null) but the error state is typed as string | undefined (line 28). Use undefined for consistency.

Apply this diff:

   const runPreview = useCallback(async () => {
     setIsLoading(true);
-    setError(null);
+    setError(undefined);
📝 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
const runPreview = useCallback(async () => {
setIsLoading(true);
setError(null);
const runPreview = useCallback(async () => {
setIsLoading(true);
setError(undefined);
🤖 Prompt for AI Agents
In apps/web/app/(app)/[emailAccountId]/clean/PreviewStep.tsx around lines 44 to
46, the component calls setError(null) but the error state is typed as string |
undefined (declared around line 28); replace the null call with
setError(undefined) (or update the state type to include null) so the value
matches the declared type and removes the type inconsistency.


const result = await cleanInboxAction(emailAccountId, {
daysOld: timeRange,
instructions: instructions || "",
action,
maxEmails: PREVIEW_RUN_COUNT,
skips: {
reply: skipReply,
starred: skipStarred,
calendar: skipCalendar,
receipt: skipReceipt,
attachment: skipAttachment,
conversation: false,
},
});

if (result?.serverError) {
setError(result.serverError);
toastError({ description: result.serverError });
} else if (result?.data?.jobId) {
setJobId(result.data.jobId);
}

setIsLoading(false);
}, [
emailAccountId,
action,
timeRange,
instructions,
skipReply,
skipStarred,
skipCalendar,
skipReceipt,
skipAttachment,
]);

const handleProcessPreviewOnly = async () => {
setIsLoadingPreview(true);
const result = await cleanInboxAction(emailAccountId, {
daysOld: timeRange,
instructions: instructions || "",
action,
maxEmails: PREVIEW_RUN_COUNT,
skips: {
reply: skipReply,
starred: skipStarred,
calendar: skipCalendar,
receipt: skipReceipt,
attachment: skipAttachment,
conversation: false,
},
});

setIsLoadingPreview(false);

if (result?.serverError) {
toastError({ description: result.serverError });
} else if (result?.data?.jobId) {
setJobId(result.data.jobId);
}
};

const handleRunOnFullInbox = async () => {
setIsLoadingFull(true);
const result = await cleanInboxAction(emailAccountId, {
daysOld: timeRange,
instructions: instructions || "",
action,
skips: {
reply: skipReply,
starred: skipStarred,
calendar: skipCalendar,
receipt: skipReceipt,
attachment: skipAttachment,
conversation: false,
},
});

setIsLoadingFull(false);

if (result?.serverError) {
toastError({ description: result.serverError });
} else if (result?.data?.jobId) {
setJobId(result.data.jobId);
}
};
Comment on lines +44 to +132
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Extract shared payload construction logic.

The payload construction is duplicated across runPreview, handleProcessPreviewOnly, and handleRunOnFullInbox. This violates the DRY principle and makes maintenance more difficult.

Apply this diff to extract a helper function:

+  const buildCleanPayload = useCallback((maxEmails?: number) => ({
+    daysOld: timeRange,
+    instructions: instructions || "",
+    action,
+    ...(maxEmails && { maxEmails }),
+    skips: {
+      reply: skipReply,
+      starred: skipStarred,
+      calendar: skipCalendar,
+      receipt: skipReceipt,
+      attachment: skipAttachment,
+      conversation: false,
+    },
+  }), [
+    timeRange,
+    instructions,
+    action,
+    skipReply,
+    skipStarred,
+    skipCalendar,
+    skipReceipt,
+    skipAttachment,
+  ]);
+
   const runPreview = useCallback(async () => {
     setIsLoading(true);
     setError(undefined);
 
-    const result = await cleanInboxAction(emailAccountId, {
-      daysOld: timeRange,
-      instructions: instructions || "",
-      action,
-      maxEmails: PREVIEW_RUN_COUNT,
-      skips: {
-        reply: skipReply,
-        starred: skipStarred,
-        calendar: skipCalendar,
-        receipt: skipReceipt,
-        attachment: skipAttachment,
-        conversation: false,
-      },
-    });
+    const result = await cleanInboxAction(
+      emailAccountId,
+      buildCleanPayload(PREVIEW_RUN_COUNT)
+    );
 
     if (result?.serverError) {
       setError(result.serverError);
@@ -68,49 +53,16 @@
     }
 
     setIsLoading(false);
-  }, [
-    emailAccountId,
-    action,
-    timeRange,
-    instructions,
-    skipReply,
-    skipStarred,
-    skipCalendar,
-    skipReceipt,
-    skipAttachment,
-  ]);
+  }, [emailAccountId, buildCleanPayload]);
 
   const handleProcessPreviewOnly = async () => {
     setIsLoadingPreview(true);
-    const result = await cleanInboxAction(emailAccountId, {
-      daysOld: timeRange,
-      instructions: instructions || "",
-      action,
-      maxEmails: PREVIEW_RUN_COUNT,
-      skips: {
-        reply: skipReply,
-        starred: skipStarred,
-        calendar: skipCalendar,
-        receipt: skipReceipt,
-        attachment: skipAttachment,
-        conversation: false,
-      },
-    });
+    const result = await cleanInboxAction(
+      emailAccountId,
+      buildCleanPayload(PREVIEW_RUN_COUNT)
+    );
 
     setIsLoadingPreview(false);
 
@@ -123,22 +75,11 @@
 
   const handleRunOnFullInbox = async () => {
     setIsLoadingFull(true);
-    const result = await cleanInboxAction(emailAccountId, {
-      daysOld: timeRange,
-      instructions: instructions || "",
-      action,
-      skips: {
-        reply: skipReply,
-        starred: skipStarred,
-        calendar: skipCalendar,
-        receipt: skipReceipt,
-        attachment: skipAttachment,
-        conversation: false,
-      },
-    });
+    const result = await cleanInboxAction(
+      emailAccountId,
+      buildCleanPayload()
+    );
 
     setIsLoadingFull(false);
🤖 Prompt for AI Agents
In apps/web/app/(app)/[emailAccountId]/clean/PreviewStep.tsx around lines 44 to
132, the payload build is duplicated in runPreview, handleProcessPreviewOnly,
and handleRunOnFullInbox; extract a small helper (e.g., buildCleanPayload) that
takes an options flag for preview to return the common object with daysOld,
instructions (fallback ""), action, skips (reply, starred, calendar, receipt,
attachment, conversation: false) and conditionally includes maxEmails:
PREVIEW_RUN_COUNT for preview flows; replace the three inline payload objects
with calls to this helper, update runPreview and handleProcessPreviewOnly to
pass preview=true (so maxEmails is included) and handleRunOnFullInbox to pass
preview=false, and adjust any useCallback dependency lists if the helper uses
local vars.


useEffect(() => {
runPreview();
}, [runPreview]);

// Use the email stream hook to get real-time email data
const { emails } = useEmailStream(emailAccountId, false, []);

// Calculate stats from the emails
const stats = useMemo(() => {
const total = emails.length;
const done = emails.filter(
(email) => email.archive || email.label || email.status === "completed",
).length;
return { total, done };
}, [emails]);

return (
<LoadingContent loading={isLoading} error={error}>
{jobId && (
<>
<div className="mb-4">
<Button
variant="outline"
onClick={() =>
router.push(`/${emailAccountId}/clean/onboarding?step=4`)
}
>
← Back
</Button>
</div>
<CardGreen className="mb-4">
<CardHeader>
<CardTitle>Preview run</CardTitle>
<CardDescription>
We're cleaning up {PREVIEW_RUN_COUNT} emails so you can see how
it works.
</CardDescription>
<CardDescription>
To undo any, hover over the "
{action === CleanAction.ARCHIVE ? "Archive" : "Mark as read"}"
badge and click undo.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex items-center gap-3">
{/* Temporarily hidden as requested */}
{/* <Button
onClick={handleProcessPreviewOnly}
loading={isLoadingPreview}
variant="secondary"
>
Process Only These {PREVIEW_RUN_COUNT} Emails
</Button> */}
<Button onClick={handleRunOnFullInbox} loading={isLoadingFull}>
Run on Full Inbox
</Button>
</div>
<CardDescription className="text-sm">
Click to process your entire mailbox
</CardDescription>
</CardContent>
</CardGreen>
<EmailFirehose threads={emails} stats={stats} action={action} />
</>
)}
</LoadingContent>
);
}
9 changes: 8 additions & 1 deletion apps/web/app/(app)/[emailAccountId]/clean/TimeRangeStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import { TypographyH3 } from "@/components/Typography";
import { timeRangeOptions } from "@/app/(app)/[emailAccountId]/clean/types";
import { useStep } from "@/app/(app)/[emailAccountId]/clean/useStep";
import { ButtonListSurvey } from "@/components/ButtonListSurvey";
import { Button } from "@/components/ui/button";

export function TimeRangeStep() {
const { onNext } = useStep();
const { onNext, onPrevious } = useStep();

const [_, setTimeRange] = useQueryState("timeRange", parseAsInteger);

Expand All @@ -30,6 +31,12 @@ export function TimeRangeStep() {
options={timeRangeOptions}
onClick={handleTimeRangeSelect}
/>

<div className="mt-6">
<Button variant="outline" onClick={onPrevious}>
Back
</Button>
</div>
Comment on lines +35 to +39
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add explicit button type for accessibility and predictability

Include type="button" to avoid implicit submit behavior and align with guidelines.

Apply:

-      <div className="mt-6">
-        <Button variant="outline" onClick={onPrevious}>
+      <div className="mt-6">
+        <Button type="button" variant="outline" onClick={onPrevious}>
           Back
         </Button>
       </div>

As per coding guidelines.

📝 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
<div className="mt-6">
<Button variant="outline" onClick={onPrevious}>
Back
</Button>
</div>
<div className="mt-6">
<Button type="button" variant="outline" onClick={onPrevious}>
Back
</Button>
</div>
🤖 Prompt for AI Agents
In apps/web/app/(app)/[emailAccountId]/clean/TimeRangeStep.tsx around lines 35
to 39, the Back Button is missing an explicit type which can cause it to act as
a form submit button; add type="button" to the Button element (e.g., <Button
type="button" ...>) so its behavior is explicit and accessible per guidelines.

</div>
);
}
5 changes: 5 additions & 0 deletions apps/web/app/(app)/[emailAccountId]/clean/useStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@ export function useStep() {
setStep(step + 1);
}, [step, setStep]);

const onPrevious = useCallback(() => {
setStep(step - 1);
}, [step, setStep]);
Comment on lines +17 to +19
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Clamp backward navigation and prevent underflow

Going back from the first step can produce invalid step values. Clamp to the intro step.

Apply:

-  const onPrevious = useCallback(() => {
-    setStep(step - 1);
-  }, [step, setStep]);
+  const onPrevious = useCallback(() => {
+    setStep(Math.max(step - 1, CleanStep.INTRO));
+  }, [step, setStep]);

Optional: if useQueryState supports functional updaters, prefer:

-  const onNext = useCallback(() => {
-    setStep(step + 1);
-  }, [step, setStep]);
+  const onNext = useCallback(() => {
+    setStep((s) => s + 1);
+  }, [setStep]);

-  const onPrevious = useCallback(() => {
-    setStep(Math.max(step - 1, CleanStep.INTRO));
-  }, [step, setStep]);
+  const onPrevious = useCallback(() => {
+    setStep((s) => Math.max(s - 1, CleanStep.INTRO));
+  }, [setStep]);
🤖 Prompt for AI Agents
In apps/web/app/(app)/[emailAccountId]/clean/useStep.tsx around lines 17 to 19,
the onPrevious handler allows the step to underflow below the intro step; change
it to clamp the new step to the intro minimum (e.g. Math.max(step - 1,
INTRO_STEP_INDEX)) so it never goes below the first valid step. If the state
setter returned by useQueryState supports functional updaters, use a functional
update form to compute prev => Math.max(prev - 1, INTRO_STEP_INDEX) to avoid
stale closures and remove step from the dependency array.


return {
step,
setStep,
onNext,
onPrevious,
};
}
Loading