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
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ function getActionType(example: string): {
if (lowerExample.includes("spam") || lowerExample.includes("mark")) {
return { type: "mark", color };
}
if (lowerExample.includes("label")) {
if (lowerExample.includes("label") || lowerExample.includes("categorize")) {
return { type: "label", color };
}
Comment on lines +74 to 76
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.

💡 Verification agent

🧩 Analysis chain

Match constants.ts: add “categorise”, “categorization”, and “tag” variants.

Prevents user prompts like “tag this” or “categorise as” from falling into “other”.

Minimal change:

-  if (lowerExample.includes("label") || lowerExample.includes("categorize")) {
+  if (/\b(label|tag|tagging|categorize|categorise|categorization|categorisation)\b/.test(lowerExample)) {
     return { type: "label", color };
   }

If you adopt the shared helper suggested in constants.ts, this becomes:

import { getActionTypeColor, isLabelLike } from "@/app/(app)/[emailAccountId]/assistant/constants";
// ...
if (isLabelLike(example)) {
  return { type: "label", color };
}

Quick check for other spots needing the same update:


🏁 Script executed:

#!/bin/bash
rg -n --type=ts --type=tsx -C2 "(includes\(['\"]label['\"]\)|categoriz(e|ation)|categoris(e|ation)|\btag(ging)?\b)" apps/web

Length of output: 158


🏁 Script executed:

#!/bin/bash
# Search for “label”, “categorize”/“categorise”, “tag”/“tagging”, and “categorization” variants in .ts/.tsx files without relying on type aliases
rg -n -C2 "(includes\(['\"]label['\"]\)|includes\(['\"]categorize['\"]\)|includes\(['\"]categorise['\"]\)|includes\(['\"]tag['\"]\)|includes\(['\"]tagging['\"]\)|categorization|categorisation)" -g "*.ts" -g "*.tsx" apps/web

Length of output: 14224


🏁 Script executed:

#!/bin/bash
# Search for isLabelLike helper definition
rg -n "isLabelLike" -C2 -g "*.ts" -g "*.tsx" apps/web

Length of output: 53


Cover all label-like variants and DRY the logic

  • In apps/web/app/(app)/[emailAccountId]/assistant/constants.ts, add:
    export function isLabelLike(example: string): boolean {
      return /\b(label|tag|tagging|categorize|categorise|categorization|categorisation)\b/.test(
        example.toLowerCase()
      );
    }
  • Replace both occurrences
    • ExamplesList.tsx (lines 74–76)
    • constants.ts (lines 84–86)
    with:
    if (isLabelLike(lowerExample)) {
      return { type: "label", color };
    }
🤖 Prompt for AI Agents
In apps/web/app/(app)/[emailAccountId]/assistant/ExamplesList.tsx (around lines
74–76) and apps/web/app/(app)/[emailAccountId]/assistant/constants.ts (around
lines 84–86), replace the ad-hoc checks for "label" and "categorize" with a
single DRY helper: add the provided isLabelLike function to constants.ts, export
it, then import isLabelLike into ExamplesList.tsx and any other file using the
old logic and change the if to call isLabelLike(lowerExample) returning { type:
"label", color } as before; remove the original inline string checks so all
label-like variants are covered consistently.


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"use client";

import { useCallback, useRef, useMemo } from "react";
import { Button } from "@/components/ui/button";
import {
SimpleRichTextEditor,
type SimpleRichTextEditorRef,
} from "@/components/editor/SimpleRichTextEditor";
import { LoadingContent } from "@/components/LoadingContent";
import { Skeleton } from "@/components/ui/skeleton";
import { useLabels } from "@/hooks/useLabels";
import { useRules } from "@/hooks/useRules";
import { toastError } from "@/components/Toast";
import { ruleToText } from "@/utils/rule/rule-to-text";
import { MessageText } from "@/components/Typography";
import { Notice } from "@/components/Notice";

export function RulesPromptFormat() {
const { data: rules, isLoading: isLoadingRules } = useRules();
const { userLabels, isLoading: isLoadingLabels } = useLabels();

const editorRef = useRef<SimpleRichTextEditorRef>(null);

const rulesText = useMemo(() => {
if (!rules) return "";

return rules
.map((rule, index) => {
const ruleText = ruleToText(rule);
return `## Rule ${index + 1}: ${rule.name}\n${rule.enabled ? "" : "(Disabled)\n"}${ruleText}`;
})
.join("\n\n---\n\n");
}, [rules]);

const onSubmit = useCallback(async () => {
const markdown = editorRef.current?.getMarkdown();
if (typeof markdown !== "string") return;
if (markdown.trim() === "") {
toastError({
description: "Please enter a prompt to create rules",
});
return;
}

// setIsSubmitting(true);
}, []);

return (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
>
<LoadingContent
loading={isLoadingLabels || isLoadingRules}
loadingComponent={<Skeleton className="min-h-[220px] w-full" />}
>
<Notice variant="info" className="mb-2">
Editing in 'Prompt' view is currently disabled. Edit using AI Chat or
'List' view instead.
</Notice>

<SimpleRichTextEditor
ref={editorRef}
defaultValue={rulesText}
minHeight={220}
userLabels={userLabels}
editable={false}
/>
</LoadingContent>

<div className="flex flex-wrap gap-2 mt-4 items-center">
<Button type="submit" size="sm" disabled>
Save
</Button>

<MessageText className="pl-2">
Editing in 'Prompt' view is currently disabled. Edit using AI chat or
'List' view instead.
</MessageText>
</div>
</form>
);
}
22 changes: 20 additions & 2 deletions apps/web/app/(app)/[emailAccountId]/assistant/RulesTabNew.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,31 @@
import { Rules } from "@/app/(app)/[emailAccountId]/assistant/Rules";
import { RulesPromptFormat } from "@/app/(app)/[emailAccountId]/assistant/RulesPromptFormat";
import { RulesPrompt } from "@/app/(app)/[emailAccountId]/assistant/RulesPromptNew";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";

export function RulesTab() {
return (
<div>
<RulesPrompt />

<h3 className="font-cal text-xl leading-7 mt-8 mb-2">Rules</h3>
<Rules showAddRuleButton={false} />
<Tabs defaultValue="list" searchParam="format">
<div className="flex justify-between items-center mt-8 mb-2">
<h3 className="font-cal text-xl leading-7">Rules</h3>

<TabsList>
<TabsTrigger value="list">List</TabsTrigger>
<TabsTrigger value="prompt">Prompt</TabsTrigger>
</TabsList>
</div>

<TabsContent value="list">
<Rules showAddRuleButton={false} />
</TabsContent>

<TabsContent value="prompt">
<RulesPromptFormat />
</TabsContent>
</Tabs>
</div>
);
}
2 changes: 1 addition & 1 deletion apps/web/app/(app)/[emailAccountId]/assistant/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export function getActionTypeColor(example: string): string {
if (lowerExample.includes("mark")) {
return ACTION_TYPE_COLORS[ActionType.MARK_READ];
}
if (lowerExample.includes("label")) {
if (lowerExample.includes("label") || lowerExample.includes("categorize")) {
return ACTION_TYPE_COLORS[ActionType.LABEL];
}
if (lowerExample.includes("digest")) {
Expand Down
7 changes: 6 additions & 1 deletion apps/web/components/editor/SimpleRichTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface SimpleRichTextEditorProps {
minHeight?: number;
userLabels?: EmailLabel[];
onClearContents?: () => void;
editable?: boolean;
}

export interface SimpleRichTextEditorRef {
Expand All @@ -36,10 +37,12 @@ export const SimpleRichTextEditor = forwardRef<
minHeight = 300,
userLabels,
onClearContents,
editable = true,
},
ref,
) => {
const editor = useEditor({
editable,
extensions: [
StarterKit.configure({
italic: false,
Expand Down Expand Up @@ -141,7 +144,9 @@ export const SimpleRichTextEditor = forwardRef<
<div
className={cn(
"rounded-md border border-input bg-background",
"focus-within:border-ring focus-within:ring-1 focus-within:ring-ring",
editable &&
"focus-within:border-ring focus-within:ring-1 focus-within:ring-ring",
!editable && "bg-muted/30 cursor-not-allowed",
)}
style={{ minHeight }}
>
Expand Down
121 changes: 121 additions & 0 deletions apps/web/utils/rule/rule-to-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type { Rule, Action } from "@prisma/client";
import {
ActionType,
CategoryFilterType,
LogicalOperator,
} from "@prisma/client";
Comment on lines +2 to +6
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.

💡 Verification agent

🧩 Analysis chain

Do not import Prisma enums in the web bundle; compare to string literals instead.

Importing @prisma/client values on the client drags server-only code into the browser and violates guidelines.

Apply:

-import type { Rule, Action } from "@prisma/client";
-import {
-  ActionType,
-  CategoryFilterType,
-  LogicalOperator,
-} from "@prisma/client";
+import type { Rule, Action } from "@prisma/client";
@@
-  if (rule.categoryFilterType && rule.categoryFilters?.length) {
+  if (rule.categoryFilterType && rule.categoryFilters?.length) {
     const categoryNames = rule.categoryFilters.map((c) => c.name).join(", ");
-    if (rule.categoryFilterType === CategoryFilterType.INCLUDE) {
+    if (rule.categoryFilterType === "INCLUDE") {
       conditions.push(`Sender is in categories: ${categoryNames}`);
     } else {
       conditions.push(`Sender is NOT in categories: ${categoryNames}`);
     }
   }
@@
-  rule.actions.forEach((action) => {
+  rule.actions.forEach((action) => {
     switch (action.type) {
-      case ActionType.ARCHIVE:
+      case "ARCHIVE":
         actions.push("Archive");
         break;
-      case ActionType.LABEL:
+      case "LABEL":
         if (action.label) {
           actions.push(`Label as @[${action.label}]`);
         }
         break;
-      case ActionType.REPLY:
+      case "REPLY":
         if (action.content) {
           actions.push(`Reply with: "${action.content}"`);
         } else {
           actions.push("Send reply");
         }
         break;
-      case ActionType.FORWARD:
+      case "FORWARD":
         if (action.to) {
           actions.push(`Forward to ${action.to}`);
         }
         break;
-      case ActionType.SEND_EMAIL:
+      case "SEND_EMAIL":
         actions.push(`Send email${action.to ? ` to ${action.to}` : ""}`);
         break;
-      case ActionType.DRAFT_EMAIL:
+      case "DRAFT_EMAIL":
         actions.push("Draft a reply");
         break;
-      case ActionType.MARK_SPAM:
+      case "MARK_SPAM":
         actions.push("Mark as spam");
         break;
-      case ActionType.MARK_READ:
+      case "MARK_READ":
         actions.push("Mark as read");
         break;
-      case ActionType.CALL_WEBHOOK:
+      case "CALL_WEBHOOK":
         if (action.url) {
           actions.push(`Call webhook: ${action.url}`);
         }
         break;
-      case ActionType.DIGEST:
+      case "DIGEST":
         actions.push("Add to digest");
         break;
-      case ActionType.MOVE_FOLDER:
+      case "MOVE_FOLDER":
         if (action.folderName) {
           actions.push(`Move to folder "${action.folderName}"`);
         }
         break;
-      case ActionType.TRACK_THREAD:
+      case "TRACK_THREAD":
         // Skip this action as it's typically internal
         break;
     }
   });
@@
-  const operator =
-    rule.conditionalOperator === LogicalOperator.OR ? " OR " : " AND ";
+  const operator = rule.conditionalOperator === "OR" ? " OR " : " AND ";

Check for any other client-side imports of @prisma/client values:

Also applies to: 43-50, 55-103, 107-109


🏁 Script executed:

#!/bin/bash
rg -nP --glob 'apps/web/**' -C2 'from\s+"@prisma/client";'

Length of output: 80559


Remove all runtime imports of @prisma/client from the web bundle
Replace every non-type import of enums or models from @prisma/client in apps/web with string literals (for enum comparisons) or move that logic into server-only modules. Client code may only use import type { … } from "@prisma/client". Run the provided ripgrep command to locate and fix all occurrences.

🤖 Prompt for AI Agents
In apps/web/utils/rule/rule-to-text.ts around lines 2 to 6, runtime-imports from
"@prisma/client" are included which will pull Prisma into the web bundle;
replace the non-type imports with type-only imports and convert any enum usages
to string literals (or move the logic into a server-only module). Specifically:
change imports to "import type { ActionType, CategoryFilterType, LogicalOperator
} from '@prisma/client'" and replace any runtime comparisons like ActionType.FOO
or LogicalOperator.AND with the equivalent string values ("FOO", "AND"); if this
file must evaluate Prisma enums at runtime, move that logic to a server-only
helper and call it via API/SSR. Run the ripgrep provided in the review to locate
and fix any other non-type imports in apps/web.


export interface RuleWithActions extends Rule {
actions: Action[];
categoryFilters?: { name: string }[];
group?: { name: string } | null;
}

export function ruleToText(rule: RuleWithActions): string {
const conditions: string[] = [];
const actions: string[] = [];

// Build conditions
if (rule.instructions) {
conditions.push(rule.instructions);
}

if (rule.from) {
conditions.push(`'From' contains "${rule.from}"`);
}

if (rule.to) {
conditions.push(`'To' contains "${rule.to}"`);
}

if (rule.subject) {
conditions.push(`'Subject' contains "${rule.subject}"`);
}

if (rule.body) {
conditions.push(`'Body' contains "${rule.body}"`);
}

// if (rule.group) {
// conditions.push(`Sender is in group "${rule.group.name}"`);
// }

if (rule.categoryFilterType && rule.categoryFilters?.length) {
const categoryNames = rule.categoryFilters.map((c) => c.name).join(", ");
if (rule.categoryFilterType === CategoryFilterType.INCLUDE) {
conditions.push(`Sender is in categories: ${categoryNames}`);
} else {
conditions.push(`Sender is NOT in categories: ${categoryNames}`);
}
}

// Build actions
rule.actions.forEach((action) => {
switch (action.type) {
case ActionType.ARCHIVE:
actions.push("Archive");
break;
case ActionType.LABEL:
if (action.label) {
actions.push(`Label as @[${action.label}]`);
}
break;
case ActionType.REPLY:
if (action.content) {
actions.push(`Reply with: "${action.content}"`);
} else {
actions.push("Send reply");
}
break;
case ActionType.FORWARD:
if (action.to) {
actions.push(`Forward to ${action.to}`);
}
break;
case ActionType.SEND_EMAIL:
actions.push(`Send email${action.to ? ` to ${action.to}` : ""}`);
break;
case ActionType.DRAFT_EMAIL:
actions.push("Draft a reply");
break;
case ActionType.MARK_SPAM:
actions.push("Mark as spam");
break;
case ActionType.MARK_READ:
actions.push("Mark as read");
break;
case ActionType.CALL_WEBHOOK:
if (action.url) {
actions.push(`Call webhook: ${action.url}`);
}
break;
case ActionType.DIGEST:
actions.push("Add to digest");
break;
case ActionType.MOVE_FOLDER:
if (action.folderName) {
actions.push(`Move to folder "${action.folderName}"`);
}
break;
case ActionType.TRACK_THREAD:
// Skip this action as it's typically internal
break;
}
});

// Combine conditions with operator
const operator =
rule.conditionalOperator === LogicalOperator.OR ? " OR " : " AND ";
const conditionText =
conditions.length > 0
? conditions.join(operator)
: "No conditions specified";

// Format the output with actions as bullet list
const actionsText =
actions.length > 0
? actions.map((action) => `- ${action}`).join("\n")
: "- No actions specified";

return `**When:**\n\n${conditionText}\n\n**Then:**\n${actionsText}`;
}
2 changes: 1 addition & 1 deletion version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v2.6.11
v2.6.12
Loading