diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ActionSummaryCard.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ActionSummaryCard.tsx index 4aebcb4e92..72ad6de151 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ActionSummaryCard.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ActionSummaryCard.tsx @@ -6,6 +6,11 @@ import { ACTION_TYPE_TEXT_COLORS, ACTION_TYPE_ICONS, } from "@/app/(app)/[emailAccountId]/assistant/constants"; +import { TooltipExplanation } from "@/components/TooltipExplanation"; +import { + AWAITING_REPLY_LABEL_NAME, + NEEDS_REPLY_LABEL_NAME, +} from "@/utils/reply-tracker/consts"; export function ActionSummaryCard({ action, @@ -18,6 +23,7 @@ export function ActionSummaryCard({ typeOptions.find((opt) => opt.value === action.type)?.label || action.type; let summaryContent: React.ReactNode = actionTypeLabel; + let tooltipText: string | undefined; switch (action.type) { case ActionType.LABEL: { @@ -59,13 +65,21 @@ export function ActionSummaryCard({ } else { summaryContent = ( <> - AI draft reply - {action.to?.value && ( - - {" "} - to {action.to.value} - - )} +
+
+ AI draft reply + {action.to?.value && ( + + {" "} + to {action.to.value} + + )} +
+ +
{summaryContent}
+ {tooltipText && } ); diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx index 1a228ed655..053bc11e0e 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx @@ -44,7 +44,6 @@ import { createRuleBody, } from "@/utils/actions/rule.validation"; import { actionInputs } from "@/utils/action-item"; -import { Select } from "@/components/Select"; import { Toggle } from "@/components/Toggle"; import { LoadingContent } from "@/components/LoadingContent"; import { TooltipExplanation } from "@/components/TooltipExplanation"; @@ -80,9 +79,17 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { isDefined } from "@/utils/types"; import { ActionSummaryCard } from "@/app/(app)/[emailAccountId]/assistant/ActionSummaryCard"; import { ConditionSummaryCard } from "@/app/(app)/[emailAccountId]/assistant/ConditionSummaryCard"; +import { + Select, + SelectContent, + SelectItem, + SelectValue, + SelectTrigger, +} from "@/components/ui/select"; +import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; +import { isDefined } from "@/utils/types"; export function Rule({ ruleId, @@ -109,15 +116,7 @@ export function RuleForm({ }) { const { emailAccountId } = useAccount(); - const { - register, - handleSubmit, - watch, - setValue, - control, - formState: { errors, isSubmitting, isSubmitted }, - trigger, - } = useForm({ + const form = useForm({ resolver: zodResolver(createRuleBody), defaultValues: rule ? { @@ -135,6 +134,16 @@ export function RuleForm({ : undefined, }); + const { + register, + handleSubmit, + watch, + setValue, + control, + formState: { errors, isSubmitting, isSubmitted }, + trigger, + } = form; + const { fields: conditionFields, append: appendCondition, @@ -305,589 +314,632 @@ export function RuleForm({ }, [alwaysEditMode]); return ( -
- {isSubmitted && Object.keys(errors).length > 0 && ( -
- - {Object.values(errors).map((error) => ( -
  • {error.message}
  • - ))} - - } - /> -
    - )} - -
    - {isNameEditMode ? ( - - ) : ( - - {watch("name")} - - + + + {isSubmitted && Object.keys(errors).length > 0 && ( +
    + + {Object.values(errors).map((error) => ( +
  • {error.message}
  • + ))} + + } + /> +
    )} -
    - {/* removed for now to keep things simpler */} - {/* {showSystemTypeBadge(rule.systemType) && ( -
    - - This rule has special preset logic that may impact your conditions - +
    + {isNameEditMode ? ( + + ) : ( + + {watch("name")} + + + )}
    - )} */} - -
    - Conditions - -
    - {isConditionsEditMode && ( - - - - - - - setValue("conditionalOperator", value as LogicalOperator) - } + +
    + Conditions + +
    + {isConditionsEditMode && ( + + + + + + + setValue("conditionalOperator", value as LogicalOperator) + } + > + + Match all conditions + + + Match any condition + + + + + )} + + {!!rule.id && ( + + + + )} - {!!rule.id && ( - + {!alwaysEditMode && ( - - )} - - {!alwaysEditMode && ( - - )} + )} +
    -
    - {errors.conditions?.root?.message && ( + {errors.conditions?.root?.message && ( +
    + +
    + )} +
    - -
    - )} - -
    - {conditionFields.map((condition, index) => ( -
    - {index > 0 && ( -
    -
    -
    -
    - {conditionalOperator === LogicalOperator.OR ? "OR" : "AND"} + {conditionFields.map((condition, index) => ( +
    + {index > 0 && ( +
    +
    +
    +
    + {conditionalOperator === LogicalOperator.OR + ? "OR" + : "AND"} +
    +
    -
    -
    - )} - {isConditionsEditMode ? ( - -
    -
    - { + const selectedType = value; + + // check if we have duplicate condition types + const prospectiveTypes = new Set( + conditions.map((c, idx) => + idx === index ? selectedType : c.type, + ), + ); + + if ( + prospectiveTypes.size !== conditions.length + ) { + toastError({ + description: + "You can only have one condition of each type.", + }); + return; // abort update + } -
    - {watch(`conditions.${index}.type`) === ConditionType.AI && ( - + + + + + + + {[ + { label: "AI", value: ConditionType.AI }, + { + label: "Static", + value: ConditionType.STATIC, + }, + // Deprecated: only show if this is the selected condition type + condition.type === ConditionType.CATEGORY + ? { + label: "Sender Category", + value: ConditionType.CATEGORY, + } + : null, + ] + .filter(isDefined) + .map((option) => ( + + {option.label} + + ))} + + + )} - error={ - ( - errors.conditions?.[index] as { - instructions?: FieldError; - } - )?.instructions - } - placeholder='e.g. Apply this rule to all "receipts"' - tooltipText="The instructions that will be passed to the AI." /> - )} +
    - {watch(`conditions.${index}.type`) === - ConditionType.STATIC && ( - <> +
    + {watch(`conditions.${index}.type`) === + ConditionType.AI && ( - - - - )} - - {watch(`conditions.${index}.type`) === - ConditionType.CATEGORY && ( - <> -
    - - setValue( - `conditions.${index}.categoryFilterType`, - value as CategoryFilterType, - ) + )} + + {watch(`conditions.${index}.type`) === + ConditionType.STATIC && ( + <> + -
    - -
    -
    - -
    -
    - - -
    - - - {categories.length ? ( - <> - ({ - label: capitalCase(category.name), - value: category.id, - }))} - selectedValues={ - new Set( - watch( - `conditions.${index}.categoryFilters`, - ), - ) + tooltipText="Only apply this rule to emails from this address. e.g. @company.com, or hello@company.com" + /> + { - setValue( - `conditions.${index}.categoryFilters`, - Array.from(selectedValues), - ); - }} - /> - {( + )?.to + } + tooltipText="Only apply this rule to emails sent to this address. e.g. @company.com, or hello@company.com" + /> + + + )} + + {watch(`conditions.${index}.type`) === + ConditionType.CATEGORY && ( + <> +
    + + setValue( + `conditions.${index}.categoryFilterType`, + value as CategoryFilterType, + ) + } + className="flex gap-6" + > +
    + +
    +
    + +
    +
    + + +
    + + + {categories.length ? ( + <> + ({ + label: capitalCase(category.name), + value: category.id, + }))} + selectedValues={ + new Set( + watch( + `conditions.${index}.categoryFilters`, + ), + ) } + setSelectedValues={(selectedValues) => { + setValue( + `conditions.${index}.categoryFilters`, + Array.from(selectedValues), + ); + }} /> - )} - - - - ) : ( -
    - - No sender categories found. - - - -
    - )} -
    - - )} + + Create category + + + + + ) : ( +
    + + No sender categories found. + + + +
    + )} +
    + + )} +
    -
    - -
    - ) : ( - - )} + + + ) : ( + + )} +
    + ))} +
    + + {isConditionsEditMode && unusedCondition && ( +
    +
    - ))} -
    + )} - {isConditionsEditMode && unusedCondition && ( -
    - -
    - )} + {showLearnedPatterns && learnedPatternGroupId && ( +
    + +
    + )} - {showLearnedPatterns && learnedPatternGroupId && ( -
    - +
    + Actions + {!alwaysEditMode && ( + + )}
    - )} -
    - Actions - {!alwaysEditMode && ( - + {actionErrors.length > 0 && ( +
    + + {actionErrors.map((error, index) => ( +
  • {error}
  • + ))} + + } + /> +
    )} -
    - {actionErrors.length > 0 && ( -
    - - {actionErrors.map((error, index) => ( -
  • {error}
  • - ))} - - } - /> +
    + {watch("actions")?.map((action, i) => + isActionsEditMode ? ( + + ) : ( + + ), + )}
    - )} - -
    - {watch("actions")?.map((action, i) => - isActionsEditMode ? ( - - ) : ( - - ), + + {isActionsEditMode && ( +
    + +
    )} -
    - {isActionsEditMode && ( -
    - + />
    - )} - -
    - - - { - setValue("automate", enabled); - }} - /> -
    -
    - - - { - setValue("runOnThreads", enabled); - }} - /> -
    +
    + -
    - {rule.id && ( -
    + +
    + {rule.id && ( + - )} - {rule.id ? ( - - ) : ( - - )} -
    - + }} + > + Delete + + )} + {rule.id ? ( + + ) : ( + + )} +
    + + ); } @@ -897,6 +949,7 @@ function ActionCard({ register, watch, setValue, + control, errors, userLabels, isLoading, @@ -910,6 +963,7 @@ function ActionCard({ register: ReturnType>["register"]; watch: ReturnType>["watch"]; setValue: ReturnType>["setValue"]; + control: ReturnType>["control"]; errors: any; userLabels: NonNullable; isLoading: boolean; @@ -933,7 +987,6 @@ function ActionCard({ // Helper function to determine if a field can use variables based on context const canFieldUseVariables = ( field: { name: string; expandable?: boolean }, - value: string, isFieldAiGenerated: boolean, ) => { // Check if the field is visible - this is handled before calling the function @@ -976,12 +1029,29 @@ function ActionCard({ return ( -
    +
    - + + + + + + + {typeOptions.map((option) => ( + + {option.label} + + ))} + + + + )} />
    -
    +
    {fields.map((field) => { const isAiGenerated = !!action[field.name]?.ai; const value = watch(`actions.${index}.${field.name}.value`) || ""; @@ -1053,7 +1123,7 @@ function ActionCard({ ) : field.name === "content" && action.type === ActionType.DRAFT_EMAIL && !setManually ? ( -
    +
    Our AI will generate a reply using your knowledge base and previous conversations with the sender @@ -1106,7 +1176,7 @@ function ActionCard({ )} {hasVariables(value) && - canFieldUseVariables(field, value, isAiGenerated) && ( + canFieldUseVariables(field, isAiGenerated) && (
    {(value || "") .split(/(\{\{.*?\}\})/g) @@ -1239,8 +1309,7 @@ function ReplyTrackerAction() { return (
    - Used for reply tracking (Reply Zero). This action tracks emails this - rule is applied to and removes the{" "} + This action tracks emails this rule is applied to and removes the{" "} {NEEDS_REPLY_LABEL_NAME} label after you reply to the email.
    @@ -1248,12 +1317,6 @@ function ReplyTrackerAction() { ); } -// function showSystemTypeBadge(systemType?: SystemType | null): boolean { -// if (systemType === SystemType.TO_REPLY) return true; -// if (systemType === SystemType.CALENDAR) return true; -// return false; -// } - export function ThreadsExplanation({ size }: { size: "sm" | "md" }) { return ( - We've configured your inbox with smart defaults to help you stay - organized. Your emails will be automatically categorized. + Your emails will be automatically categorized. - Want to customize further? You can chat with the assistant to - create custom rules and fine-tune your preferences anytime. + Want to customize further? You can update your rules on the + Assistant page and fine-tune your preferences anytime.
    diff --git a/apps/web/components/Combobox.tsx b/apps/web/components/Combobox.tsx index 511182b2ef..49d604f930 100644 --- a/apps/web/components/Combobox.tsx +++ b/apps/web/components/Combobox.tsx @@ -41,7 +41,7 @@ export function Combobox(props: { // biome-ignore lint/a11y/useSemanticElements: role="combobox" aria-expanded={open} - className="w-full justify-between sm:w-[500px]" + className="w-full justify-between" > {(value && props.options.find((option) => option.value === value)?.label) || diff --git a/apps/web/utils/action-display.ts b/apps/web/utils/action-display.ts index d64f6bcd3d..9f5227a178 100644 --- a/apps/web/utils/action-display.ts +++ b/apps/web/utils/action-display.ts @@ -10,6 +10,8 @@ export function getActionDisplay(action: { return "Draft Reply"; case ActionType.LABEL: return action.label ? `Label: ${action.label}` : "Label"; + case ActionType.ARCHIVE: + return "Skip Inbox"; case ActionType.MARK_READ: return "Mark Read"; case ActionType.MARK_SPAM: @@ -19,7 +21,7 @@ export function getActionDisplay(action: { case ActionType.CALL_WEBHOOK: return "Call Webhook"; case ActionType.TRACK_THREAD: - return "Track Thread"; + return "Auto-update reply label"; default: // Default to capital case for other action types return capitalCase(action.type); diff --git a/version.txt b/version.txt index 01558e339a..b4e17366c0 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v1.5.3 \ No newline at end of file +v1.5.4 \ No newline at end of file