diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/AddRuleDialog.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/AddRuleDialog.tsx new file mode 100644 index 0000000000..af366e36a5 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/assistant/AddRuleDialog.tsx @@ -0,0 +1,19 @@ +import { PlusIcon } from "lucide-react"; +import { RulesPrompt } from "@/app/(app)/[emailAccountId]/assistant/RulesPromptNew"; +import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; + +export function AddRuleDialog() { + return ( + + + + + + + + + ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/AvailableActionsPanel.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/AvailableActionsPanel.tsx new file mode 100644 index 0000000000..2f62adf69f --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/assistant/AvailableActionsPanel.tsx @@ -0,0 +1,66 @@ +import { ActionType } from "@prisma/client"; +import { Card, CardContent } from "@/components/ui/card"; +import { getActionIcon } from "@/utils/action-display"; +import { SectionHeader } from "@/components/Typography"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { + getAvailableActions, + getExtraActions, +} from "@/utils/ai/rule/create-rule-schema"; + +const actionNames: Record = { + [ActionType.LABEL]: "Label", + [ActionType.MOVE_FOLDER]: "Move to folder", + [ActionType.ARCHIVE]: "Archive", + [ActionType.DRAFT_EMAIL]: "Draft replies", + [ActionType.REPLY]: "Send replies", + [ActionType.FORWARD]: "Forward", + [ActionType.MARK_READ]: "Mark as read", + [ActionType.MARK_SPAM]: "Mark as spam", + [ActionType.SEND_EMAIL]: "Send email", + [ActionType.CALL_WEBHOOK]: "Call webhook", + [ActionType.DIGEST]: "Add to digest", + [ActionType.TRACK_THREAD]: "Track thread", +}; + +export function AvailableActionsPanel() { + const { provider } = useAccount(); + return ( + + +
+ + +
+
+
+ ); +} + +function ActionSection({ + title, + actions, +}: { + title: string; + actions: ActionType[]; +}) { + return ( +
+ {title} +
+ {actions.map((actionType) => { + const Icon = getActionIcon(actionType); + return ( +
+ + {actionNames[actionType]} +
+ ); + })} +
+
+ ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ExamplesList.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ExamplesList.tsx index 61df6dccf8..399e85c12a 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ExamplesList.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ExamplesList.tsx @@ -2,9 +2,13 @@ import { memo } from "react"; import { convertLabelsToDisplay } from "@/utils/mention"; import { SectionHeader } from "@/components/Typography"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { getActionTypeColor } from "@/app/(app)/[emailAccountId]/assistant/constants"; import { Button } from "@/components/ui/button"; import { getExamplePrompts } from "@/app/(app)/[emailAccountId]/assistant/examples"; +import { getActionIcon } from "@/utils/action-display"; +import { getActionColor } from "@/components/PlanBadge"; +import { ActionType } from "@prisma/client"; +import type { Color } from "@/components/Badge"; +import { cn } from "@/utils"; function PureExamples({ examples, @@ -26,7 +30,9 @@ function PureExamples({
{examplePrompts.map((example) => { - const { color } = getActionType(example); + const actionType = getActionType(example); + const Icon = actionType ? getActionIcon(actionType) : null; + const color = actionType ? getActionColor(actionType) : "gray"; return ( + ); + })} +
+ ); +} + +export const ExamplesGrid = memo(PureExamplesGrid); + +function getActionType(example: string): ActionType | null { const lowerExample = example.toLowerCase(); - const color = getActionTypeColor(example); if (lowerExample.includes("forward")) { - return { type: "forward", color }; + return ActionType.FORWARD; } - if (lowerExample.includes("draft") || lowerExample.includes("reply")) { - return { type: "reply", color }; + if (lowerExample.includes("draft")) { + return ActionType.DRAFT_EMAIL; + } + if (lowerExample.includes("reply")) { + return ActionType.REPLY; } if (lowerExample.includes("archive")) { - return { type: "archive", color }; + return ActionType.ARCHIVE; + } + if (lowerExample.includes("spam")) { + return ActionType.MARK_SPAM; } - if (lowerExample.includes("spam") || lowerExample.includes("mark")) { - return { type: "mark", color }; + if (lowerExample.includes("mark")) { + return ActionType.MARK_READ; } if (lowerExample.includes("label") || lowerExample.includes("categorize")) { - return { type: "label", color }; + return ActionType.LABEL; } - return { type: "other", color }; + return null; +} + +function getIconColorClass(color: Color): string { + switch (color) { + case "green": + return "text-green-600 dark:text-green-400"; + case "yellow": + return "text-yellow-600 dark:text-yellow-400"; + case "blue": + return "text-blue-600 dark:text-blue-400"; + case "red": + return "text-red-600 dark:text-red-400"; + case "purple": + return "text-purple-600 dark:text-purple-400"; + case "indigo": + return "text-indigo-600 dark:text-indigo-400"; + default: + return "text-gray-600 dark:text-gray-400"; + } } diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/RuleDialog.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/RuleDialog.tsx index 0e15f26ed2..915f1676e2 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/RuleDialog.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/RuleDialog.tsx @@ -12,6 +12,8 @@ import { LoadingContent } from "@/components/LoadingContent"; import { useRule } from "@/hooks/useRule"; import type { CreateRuleBody } from "@/utils/actions/rule.validation"; import { useDialogState } from "@/hooks/useDialogState"; +import { ActionType, LogicalOperator } from "@prisma/client"; +import { ConditionType } from "@/utils/config"; interface RuleDialogProps { ruleId?: string; @@ -78,11 +80,19 @@ export function RuleDialog({ )} @@ -1221,8 +1221,8 @@ function ActionCard({ !setManually ? (
- Our AI will generate a reply using your knowledge base - and previous conversations with the sender + Our AI will generate a reply based on your email history + and knowledge base
+
)} @@ -247,11 +244,22 @@ export function Rules({ { + if (isColdEmailBlocker) { + coldEmailDialog.onOpen(); + } else { + ruleDialog.onOpen({ + ruleId: rule.id, + editMode: false, + }); + } + }} > - - { - if (isColdEmailBlocker) { - coldEmailDialog.onOpen(); - } else { - ruleDialog.onOpen({ - ruleId: rule.id, - editMode: false, - }); - } - }} - > - - View - + e.stopPropagation()} + > { if (isColdEmailBlocker) { @@ -523,17 +495,20 @@ export function ActionBadges({ provider: string; }) { return ( -
+
{sortActionsByPriority(actions).map((action) => { // Hidden for simplicity if (action.type === ActionType.TRACK_THREAD) return null; + const Icon = getActionIcon(action.type); + return ( + {getActionDisplay(action, provider)} ); @@ -549,14 +524,3 @@ function NoRules() { ); } - -function AddRuleButton({ onClick }: { onClick: () => void }) { - return ( -
- -
- ); -} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/RulesPromptNew.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/RulesPromptNew.tsx index 038d9fa9b4..2ff8fa7e6d 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/RulesPromptNew.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/RulesPromptNew.tsx @@ -22,11 +22,12 @@ import { useLabels } from "@/hooks/useLabels"; import { RuleDialog } from "@/app/(app)/[emailAccountId]/assistant/RuleDialog"; import { useDialogState } from "@/hooks/useDialogState"; import { useRules } from "@/hooks/useRules"; -import { Examples } from "@/app/(app)/[emailAccountId]/assistant/ExamplesList"; +import { ExamplesGrid } from "@/app/(app)/[emailAccountId]/assistant/ExamplesList"; import { AssistantOnboarding } from "@/app/(app)/[emailAccountId]/assistant/AssistantOnboarding"; import { CreatedRulesModal } from "@/app/(app)/[emailAccountId]/assistant/CreatedRulesModal"; import type { CreateRuleResult } from "@/utils/rule/types"; import { toastError } from "@/components/Toast"; +import { AvailableActionsPanel } from "@/app/(app)/[emailAccountId]/assistant/AvailableActionsPanel"; export function RulesPrompt() { const { emailAccountId, provider } = useAccount(); @@ -50,6 +51,7 @@ export function RulesPrompt() { provider={provider} examples={examples} onOpenPersonaDialog={onOpenPersonaDialog} + onHideExamples={() => setPersona(null)} /> void; + onHideExamples: () => void; }) { const { mutate } = useRules(); const { userLabels, isLoading: isLoadingLabels } = useLabels(); @@ -146,114 +150,75 @@ function RulesPromptForm({ return (
-
- {examples && ( - - )} - -
{ - e.preventDefault(); - onSubmit(); - }} - > -
+
+
+ { + e.preventDefault(); + onSubmit(); + }} + > - -
- -
- } - > - + } + > + - + /> + -
- - - +
+ - {/* - return result; - }, - { - loading: "Generating prompt...", - success: "Prompt generated successfully!", - error: (err) => { - return `Error generating prompt: ${err.message}`; - }, - }, - ); - }} - loading={isGenerating} + - */} +
-
- + +
+ +
+ {examples && ( +
+ +
+ +
+
+ )} + - + +
+

Your inbox rules

- -
-

Rules

+ + List + Prompt + - - List - Prompt - -
+ +
- - - + + + - - - -
-
+ + + + ); } diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/examples.ts b/apps/web/app/(app)/[emailAccountId]/assistant/examples.ts index ed88ab4f09..f6a5f4a1be 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/examples.ts +++ b/apps/web/app/(app)/[emailAccountId]/assistant/examples.ts @@ -43,8 +43,8 @@ function processPromptsWithTerminology( } const commonPrompts = [ - "Label urgent emails as @[Urgent]", "Label emails from @mycompany.com addresses as @[Team]", + "Label urgent emails as @[Urgent]", ]; const examplePromptsBase = [ diff --git a/apps/web/app/(app)/[emailAccountId]/automation/page.tsx b/apps/web/app/(app)/[emailAccountId]/automation/page.tsx index 4c3f5a8e09..cf774837f9 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/page.tsx @@ -124,18 +124,17 @@ export default async function AutomationPage({
-
- } - title="Getting started with AI Assistant" - description={ - "Learn how to use the AI Assistant to automatically label, archive, and more." - } - videoSrc="https://www.youtube.com/embed/SoeNDVr7ve4" - thumbnailSrc="https://img.youtube.com/vi/SoeNDVr7ve4/0.jpg" - storageKey="ai-assistant-onboarding-video" - /> -
+ } + title="Getting started with AI Assistant" + description={ + "Learn how to use the AI Assistant to automatically label, archive, and more." + } + videoSrc="https://www.youtube.com/embed/SoeNDVr7ve4" + thumbnailSrc="https://img.youtube.com/vi/SoeNDVr7ve4/0.jpg" + storageKey="ai-assistant-onboarding-video" + /> diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx index e88ec64a6e..775b7ab1d6 100644 --- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx @@ -234,18 +234,17 @@ export function BulkUnsubscribe() { }} /> -
- } - title="Getting started with Bulk Unsubscribe" - description={ - "Learn how to use the Bulk Unsubscribe to unsubscribe from and archive unwanted emails." - } - videoSrc="https://www.youtube.com/embed/T1rnooV4OYc" - thumbnailSrc="https://img.youtube.com/vi/T1rnooV4OYc/0.jpg" - storageKey="bulk-unsubscribe-onboarding-video" - /> -
+ } + title="Getting started with Bulk Unsubscribe" + description={ + "Learn how to use the Bulk Unsubscribe to unsubscribe from and archive unwanted emails." + } + videoSrc="https://www.youtube.com/embed/T1rnooV4OYc" + thumbnailSrc="https://img.youtube.com/vi/T1rnooV4OYc/0.jpg" + storageKey="bulk-unsubscribe-onboarding-video" + />
diff --git a/apps/web/app/(landing)/components/page.tsx b/apps/web/app/(landing)/components/page.tsx index b725532dfb..c8c569f7a0 100644 --- a/apps/web/app/(landing)/components/page.tsx +++ b/apps/web/app/(landing)/components/page.tsx @@ -28,8 +28,11 @@ import { import { TooltipExplanation } from "@/components/TooltipExplanation"; import { Suspense } from "react"; import { PremiumAiAssistantAlert } from "@/components/PremiumAlert"; -import { PremiumTier } from "@prisma/client"; +import { ActionType, PremiumTier } from "@prisma/client"; import { SettingCard } from "@/components/SettingCard"; +import { IconCircle } from "@/app/(app)/[emailAccountId]/onboarding/IconCircle"; +import { ActionBadges } from "@/app/(app)/[emailAccountId]/assistant/Rules"; +import { DismissibleVideoCard } from "@/components/VideoCard"; export const maxDuration = 3; @@ -219,6 +222,107 @@ export default function Components() {
+
+
DismissibleVideoCard
+
+ } + title="Getting started with AI Assistant" + description={ + "Learn how to use the AI Assistant to automatically label, archive, and more." + } + videoSrc="https://www.youtube.com/embed/SoeNDVr7ve4" + thumbnailSrc="https://img.youtube.com/vi/SoeNDVr7ve4/0.jpg" + storageKey={`video-dismissible-${Date.now()}`} + /> +
+
+ +
+
IconCircle
+
+ +
+
+ +
+
ActionBadges
+
+ +
+
+
MultiSelectFilter
diff --git a/apps/web/components/ExpandableText.tsx b/apps/web/components/ExpandableText.tsx index 204229eb33..28c9b34123 100644 --- a/apps/web/components/ExpandableText.tsx +++ b/apps/web/components/ExpandableText.tsx @@ -42,7 +42,10 @@ export function ExpandableText({ setIsExpanded(!isExpanded)} + onClick={(e) => { + e.stopPropagation(); + setIsExpanded(!isExpanded); + }} className="mt-1 flex items-center text-xs text-muted-foreground hover:text-primary" whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} diff --git a/apps/web/components/PlanBadge.tsx b/apps/web/components/PlanBadge.tsx index c128f869c9..c63877c870 100644 --- a/apps/web/components/PlanBadge.tsx +++ b/apps/web/components/PlanBadge.tsx @@ -214,9 +214,18 @@ export function getActionColor(actionType: ActionType): Color { case ActionType.MARK_READ: return "yellow"; case ActionType.LABEL: + case ActionType.MOVE_FOLDER: return "blue"; - default: + case ActionType.MARK_SPAM: + return "red"; + case ActionType.CALL_WEBHOOK: + case ActionType.TRACK_THREAD: + case ActionType.DIGEST: return "purple"; + default: { + const exhaustiveCheck: never = actionType; + return exhaustiveCheck; + } } } diff --git a/apps/web/components/PremiumAlert.tsx b/apps/web/components/PremiumAlert.tsx index 0d523ea54e..76185639cf 100644 --- a/apps/web/components/PremiumAlert.tsx +++ b/apps/web/components/PremiumAlert.tsx @@ -81,7 +81,11 @@ export function PremiumAiAssistantAlert({ icon={} title={`${businessTierName} Plan Required`} description={`Switch to the ${businessTierName} plan to use this feature.`} - action={} + action={ + + } /> ) : showSetApiKey ? ( + } @@ -99,7 +103,11 @@ export function PremiumAiAssistantAlert({ icon={} title="Premium Feature" description={`This is a premium feature. Upgrade to the ${businessTierName} plan.`} - action={} + action={ + + } /> )} diff --git a/apps/web/utils/action-display.ts b/apps/web/utils/action-display.tsx similarity index 53% rename from apps/web/utils/action-display.ts rename to apps/web/utils/action-display.tsx index 029c03c6ea..b9b9e855f1 100644 --- a/apps/web/utils/action-display.ts +++ b/apps/web/utils/action-display.tsx @@ -1,6 +1,20 @@ import { capitalCase } from "capital-case"; import { ActionType } from "@prisma/client"; import { getEmailTerminology } from "@/utils/terminology"; +import { + ArchiveIcon, + FolderInputIcon, + ForwardIcon, + ReplyIcon, + ShieldCheckIcon, + SendIcon, + TagIcon, + WebhookIcon, + FileTextIcon, + EyeIcon, + MailIcon, + NewspaperIcon, +} from "lucide-react"; export function getActionDisplay( action: { @@ -21,7 +35,7 @@ export function getActionDisplay( return "Draft Reply"; case ActionType.LABEL: return action.label - ? `${terminology.label.action}: ${action.label}` + ? `${terminology.label.action} as '${action.label}'` : terminology.label.action; case ActionType.ARCHIVE: return "Skip Inbox"; @@ -37,10 +51,43 @@ export function getActionDisplay( return `Auto-update reply ${terminology.label.singular}`; case ActionType.MOVE_FOLDER: return action.folderName - ? `Folder: ${action.folderName}` + ? `Move to '${action.folderName}' folder` : "Move to folder"; default: // Default to capital case for other action types return capitalCase(action.type); } } + +export function getActionIcon(actionType: ActionType) { + switch (actionType) { + case ActionType.LABEL: + return TagIcon; + case ActionType.ARCHIVE: + return ArchiveIcon; + case ActionType.MOVE_FOLDER: + return FolderInputIcon; + case ActionType.DRAFT_EMAIL: + return FileTextIcon; + case ActionType.REPLY: + return ReplyIcon; + case ActionType.SEND_EMAIL: + return SendIcon; + case ActionType.FORWARD: + return ForwardIcon; + case ActionType.MARK_SPAM: + return ShieldCheckIcon; + case ActionType.MARK_READ: + return MailIcon; + case ActionType.CALL_WEBHOOK: + return WebhookIcon; + case ActionType.TRACK_THREAD: + return EyeIcon; + case ActionType.DIGEST: + return NewspaperIcon; + default: { + const exhaustiveCheck: never = actionType; + return exhaustiveCheck; + } + } +} diff --git a/apps/web/utils/action-sort.ts b/apps/web/utils/action-sort.ts index d196529731..310572bcde 100644 --- a/apps/web/utils/action-sort.ts +++ b/apps/web/utils/action-sort.ts @@ -8,19 +8,19 @@ import { ActionType } from "@prisma/client"; const ACTION_TYPE_PRIORITY_ORDER: ActionType[] = [ ActionType.LABEL, - ActionType.ARCHIVE, ActionType.MOVE_FOLDER, - + ActionType.ARCHIVE, ActionType.MARK_READ, - ActionType.MARK_SPAM, ActionType.DRAFT_EMAIL, ActionType.REPLY, ActionType.SEND_EMAIL, ActionType.FORWARD, - ActionType.CALL_WEBHOOK, ActionType.DIGEST, + + ActionType.MARK_SPAM, + ActionType.CALL_WEBHOOK, ActionType.TRACK_THREAD, ]; diff --git a/apps/web/utils/ai/rule/create-rule-schema.ts b/apps/web/utils/ai/rule/create-rule-schema.ts index 2026103fdf..1d1b1f2490 100644 --- a/apps/web/utils/ai/rule/create-rule-schema.ts +++ b/apps/web/utils/ai/rule/create-rule-schema.ts @@ -6,6 +6,7 @@ import { } from "@prisma/client"; import { delayInMinutesSchema } from "@/utils/actions/rule.validation"; import { isMicrosoftProvider } from "@/utils/email/provider-types"; +import { isDefined } from "@/utils/types"; const conditionSchema = z .object({ @@ -34,17 +35,32 @@ const conditionSchema = z }) .describe("The conditions to match"); +export function getAvailableActions(provider: string) { + const availableActions: ActionType[] = [ + ActionType.LABEL, + ...(isMicrosoftProvider(provider) ? [ActionType.MOVE_FOLDER] : []), + ActionType.ARCHIVE, + ActionType.MARK_READ, + ActionType.DRAFT_EMAIL, + ActionType.REPLY, + ActionType.FORWARD, + ActionType.MARK_SPAM, + ].filter(isDefined); + return availableActions as [ActionType, ...ActionType[]]; +} + +export const getExtraActions = () => [ + ActionType.DIGEST, + ActionType.CALL_WEBHOOK, +]; + const actionSchema = (provider: string) => z.object({ type: z - .enum( - isMicrosoftProvider(provider) - ? (Object.values(ActionType) as [ActionType, ...ActionType[]]) - : (Object.values(ActionType).filter( - (type) => type !== ActionType.MOVE_FOLDER, - ) as [ActionType, ...ActionType[]]), - ) - .describe("The type of the action"), + .enum([...getAvailableActions(provider), ...getExtraActions()]) + .describe( + `The type of the action. '${ActionType.DIGEST}' means emails will be added to the digest email the user receives. ${isMicrosoftProvider(provider) ? `'${ActionType.LABEL}' means emails will be categorized in Outlook.` : ""}`, + ), fields: z .object({ label: z diff --git a/version.txt b/version.txt index 5114997f44..c0151c8a56 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.9.48 +v2.10.1