- {result ? (
-
+ {results.length > 0 ? (
+
) : (
No rule matched
)}
diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/History.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/History.tsx
index f3124576ee..3342290c8d 100644
--- a/apps/web/app/(app)/[emailAccountId]/assistant/History.tsx
+++ b/apps/web/app/(app)/[emailAccountId]/assistant/History.tsx
@@ -1,9 +1,8 @@
"use client";
-import useSWR from "swr";
import { useQueryState, parseAsInteger, parseAsString } from "nuqs";
import { LoadingContent } from "@/components/LoadingContent";
-import type { PlanHistoryResponse } from "@/app/api/user/planned/history/route";
+import type { GetExecutedRulesResponse } from "@/app/api/user/executed-rules/history/route";
import { AlertBasic } from "@/components/Alert";
import { Card } from "@/components/ui/card";
import {
@@ -23,14 +22,13 @@ import { Badge } from "@/components/Badge";
import { RulesSelect } from "@/app/(app)/[emailAccountId]/assistant/RulesSelect";
import { useAccount } from "@/providers/EmailAccountProvider";
import { useChat } from "@/providers/ChatProvider";
+import { useExecutedRules } from "@/hooks/useExecutedRules";
export function History() {
const [page] = useQueryState("page", parseAsInteger.withDefault(1));
const [ruleId] = useQueryState("ruleId", parseAsString.withDefault("all"));
- const { data, isLoading, error } = useSWR
(
- `/api/user/planned/history?page=${page}&ruleId=${ruleId}`,
- );
+ const { data, isLoading, error } = useExecutedRules({ page, ruleId });
return (
<>
@@ -62,7 +60,7 @@ function HistoryTable({
data,
totalPages,
}: {
- data: PlanHistoryResponse["executedRules"];
+ data: GetExecutedRulesResponse["executedRules"];
totalPages: number;
}) {
const { userEmail } = useAccount();
@@ -78,19 +76,19 @@ function HistoryTable({
- {data.map((p) => (
-
+ {data.map((er) => (
+
- {!p.automated && (
+ {!er.automated && (
Applied manually
@@ -98,10 +96,11 @@ function HistoryTable({
{/* */}
diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ProcessResultDisplay.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ProcessResultDisplay.tsx
index fca9f6a93d..f6ee562dec 100644
--- a/apps/web/app/(app)/[emailAccountId]/assistant/ProcessResultDisplay.tsx
+++ b/apps/web/app/(app)/[emailAccountId]/assistant/ProcessResultDisplay.tsx
@@ -1,5 +1,7 @@
"use client";
+import groupBy from "lodash/groupBy";
+import sortBy from "lodash/sortBy";
import { capitalCase } from "capital-case";
import { CheckCircle2Icon, EyeIcon, ExternalLinkIcon } from "lucide-react";
import type { RunRulesResult } from "@/utils/ai/choose-rule/run-rules";
@@ -11,17 +13,18 @@ import { ActionType } from "@prisma/client";
import { useRuleDialog } from "./RuleDialog";
export function ProcessResultDisplay({
- result,
+ results,
prefix,
}: {
- result: RunRulesResult;
+ results: RunRulesResult[];
prefix?: string;
}) {
const { ruleDialog, RuleDialogComponent } = useRuleDialog();
- if (!result) return null;
+ if (!results.length) return null;
- if (!result.rule) {
+ if (results.length === 1 && results[0].rule === null) {
+ const result = results[0];
return (
{
+ return result.createdAt.toString();
+ });
+
+ const sortedBatches = sortBy(
+ Object.entries(groupedResults),
+ ([, batchResults]) => {
+ return -batchResults[0]?.createdAt.getTime(); // Negative for descending order
+ },
+ );
+
return (
- <>
-
- }
- >
-
- {prefix ? prefix : ""}
- {result.rule.name}
-
-
-
+
+ {sortedBatches.map(([date, batchResults], batchIndex) => (
+
+ {batchIndex === 1 && sortedBatches.length > 1 && (
+
Previous:
+ )}
+
+ {batchResults.map((result, resultIndex) => (
+
+ }
+ >
+
+ {prefix ?? ""}
+ {result.rule?.name}
+
+
+
+ ))}
+
+
+ ))}
- >
+
);
}
diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx
index da35f02dee..0ae2446d7a 100644
--- a/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx
+++ b/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx
@@ -25,12 +25,7 @@ import { Card } from "@/components/ui/card";
import type { RunRulesResult } from "@/utils/ai/choose-rule/run-rules";
import { SearchForm } from "@/components/SearchForm";
import type { BatchExecutedRulesResponse } from "@/app/api/user/executed-rules/batch/route";
-import {
- isAIRule,
- isCategoryRule,
- isGroupRule,
- isStaticRule,
-} from "@/utils/condition";
+import { isAIRule, isGroupRule, isStaticRule } from "@/utils/condition";
import { BulkRunRules } from "@/app/(app)/[emailAccountId]/assistant/BulkRunRules";
import { cn } from "@/utils";
import { TestCustomEmailForm } from "@/app/(app)/[emailAccountId]/assistant/TestCustomEmailForm";
@@ -114,37 +109,36 @@ export function ProcessRulesContent({ testMode }: { testMode: boolean }) {
// only show test rules form if we have an AI rule. this form won't match group/static rules which will confuse users
const hasAiRules = rules?.some(
- (rule) =>
- isAIRule(rule) &&
- !isGroupRule(rule) &&
- !isStaticRule(rule) &&
- !isCategoryRule(rule),
+ (rule) => isAIRule(rule) && !isGroupRule(rule) && !isStaticRule(rule),
);
const isRunningAllRef = useRef(false);
const [isRunningAll, setIsRunningAll] = useState(false);
const [currentPageLimit, setCurrentPageLimit] = useState(testMode ? 1 : 10);
const [isRunning, setIsRunning] = useState>({});
- const [results, setResults] = useState>({});
+ const [resultsMap, setResultsMap] = useState<
+ Record
+ >({});
const handledThreadsRef = useRef(new Set());
// Merge existing rules with results
const allResults = useMemo(() => {
- const merged = { ...results };
+ const merged = { ...resultsMap };
if (existingRules?.rulesMap) {
for (const [messageId, rule] of Object.entries(existingRules.rulesMap)) {
if (!merged[messageId]) {
- merged[messageId] = {
- rule: rule.rule,
- actionItems: rule.actionItems,
- reason: rule.reason,
+ merged[messageId] = rule.map((r) => ({
+ rule: r.rule,
+ actionItems: r.actionItems,
+ reason: r.reason,
existing: true,
- };
+ createdAt: r.createdAt,
+ }));
}
}
}
return merged;
- }, [results, existingRules]);
+ }, [resultsMap, existingRules]);
const onRun = useCallback(
async (message: Message, rerun?: boolean) => {
@@ -162,7 +156,7 @@ export function ProcessRulesContent({ testMode }: { testMode: boolean }) {
description: result.serverError,
});
} else if (result?.data) {
- setResults((prev) => ({ ...prev, [message.id]: result.data! }));
+ setResultsMap((prev) => ({ ...prev, [message.id]: result.data! }));
}
setIsRunning((prev) => ({ ...prev, [message.id]: false }));
},
@@ -296,7 +290,7 @@ export function ProcessRulesContent({ testMode }: { testMode: boolean }) {
message={message}
userEmail={userEmail}
isRunning={isRunning[message.id]}
- result={allResults[message.id]}
+ results={allResults[message.id]}
onRun={(rerun) => onRun(message, rerun)}
testMode={testMode}
setInput={setInput}
@@ -332,7 +326,7 @@ function ProcessRulesRow({
message,
userEmail,
isRunning,
- result,
+ results,
onRun,
testMode,
setInput,
@@ -340,7 +334,7 @@ function ProcessRulesRow({
message: Message;
userEmail: string;
isRunning: boolean;
- result: RunRulesResult;
+ results: RunRulesResult[];
onRun: (rerun?: boolean) => void;
testMode: boolean;
setInput: (input: string) => void;
@@ -363,15 +357,15 @@ function ProcessRulesRow({
labelIds={message.labelIds}
/>
- {result ? (
+ {results ? (
<>
))}
diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx
index b5e39ecf0f..31d1d88c31 100644
--- a/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx
+++ b/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx
@@ -110,7 +110,6 @@ export function Rules({
runOnThreads: false,
automate: true,
actions: getDefaultActions(systemType, provider),
- categoryFilters: [],
group: null,
emailAccountId: emailAccountId,
createdAt: new Date(),
diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/TestCustomEmailForm.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/TestCustomEmailForm.tsx
index 2b61203abc..3a04bc4b02 100644
--- a/apps/web/app/(app)/[emailAccountId]/assistant/TestCustomEmailForm.tsx
+++ b/apps/web/app/(app)/[emailAccountId]/assistant/TestCustomEmailForm.tsx
@@ -17,7 +17,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useAccount } from "@/providers/EmailAccountProvider";
export const TestCustomEmailForm = () => {
- const [testResult, setTestResult] = useState();
+ const [testResults, setTestResult] = useState();
const { emailAccountId } = useAccount();
const {
@@ -60,9 +60,9 @@ export const TestCustomEmailForm = () => {
Test
- {testResult && (
+ {testResults && (
)}
diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/constants.ts b/apps/web/app/(app)/[emailAccountId]/assistant/constants.ts
index 8b7936c573..703c494690 100644
--- a/apps/web/app/(app)/[emailAccountId]/assistant/constants.ts
+++ b/apps/web/app/(app)/[emailAccountId]/assistant/constants.ts
@@ -8,7 +8,6 @@ import {
MailOpenIcon,
ShieldCheckIcon,
WebhookIcon,
- EyeIcon,
FileTextIcon,
FolderInputIcon,
} from "lucide-react";
diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/rule/create/page.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/rule/create/page.tsx
index fa36475bf4..60072bedaf 100644
--- a/apps/web/app/(app)/[emailAccountId]/assistant/rule/create/page.tsx
+++ b/apps/web/app/(app)/[emailAccountId]/assistant/rule/create/page.tsx
@@ -9,7 +9,6 @@ export default async function CreateRulePage(props: {
example?: string;
groupId?: string;
type?: CoreConditionType;
- categoryId?: string;
label?: string;
}>;
}) {
@@ -33,7 +32,7 @@ export default async function CreateRulePage(props: {
]
: [],
conditions: searchParams.type
- ? [getEmptyCondition(searchParams.type, searchParams.categoryId)]
+ ? [getEmptyCondition(searchParams.type)]
: [],
runOnThreads: true,
}
diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx
index e5f4dd6e95..d98745fb50 100644
--- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx
+++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx
@@ -35,7 +35,6 @@ import {
BulkUnsubscribeRowDesktop,
} from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop";
import { Card } from "@/components/ui/card";
-import { ShortcutTooltip } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/ShortcutTooltip";
import { SearchBar } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/SearchBar";
import { useToggleSelect } from "@/hooks/useToggleSelect";
import { BulkActions } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkActions";
diff --git a/apps/web/app/(app)/[emailAccountId]/debug/rule-history/[ruleId]/page.tsx b/apps/web/app/(app)/[emailAccountId]/debug/rule-history/[ruleId]/page.tsx
index 3757fb9394..e0505937ba 100644
--- a/apps/web/app/(app)/[emailAccountId]/debug/rule-history/[ruleId]/page.tsx
+++ b/apps/web/app/(app)/[emailAccountId]/debug/rule-history/[ruleId]/page.tsx
@@ -160,21 +160,6 @@ export default async function RuleHistoryPage(props: {
)}
- {history.categoryFilterType && (
-
System Type
diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts
index b95868994d..0af68609c0 100644
--- a/apps/web/app/api/chat/route.ts
+++ b/apps/web/app/api/chat/route.ts
@@ -9,6 +9,7 @@ import prisma from "@/utils/prisma";
import type { Prisma } from "@prisma/client";
import { convertToUIMessages } from "@/components/assistant-chat/helpers";
import { captureException } from "@/utils/error";
+import { messageContextSchema } from "@/app/api/chat/validation";
export const maxDuration = 120;
@@ -26,6 +27,7 @@ const assistantInputSchema = z.object({
role: z.enum(["user"]),
parts: z.array(textPartSchema),
}),
+ context: messageContextSchema.optional(),
});
export const POST = withEmailAccount(async (request) => {
@@ -58,7 +60,7 @@ export const POST = withEmailAccount(async (request) => {
);
}
- const { message } = data;
+ const { message, context } = data;
const uiMessages = [...convertToUIMessages(chat), message];
await saveChatMessage({
@@ -73,6 +75,7 @@ export const POST = withEmailAccount(async (request) => {
messages: convertToModelMessages(uiMessages),
emailAccountId,
user,
+ context,
});
return result.toUIMessageStreamResponse({
diff --git a/apps/web/app/api/chat/validation.ts b/apps/web/app/api/chat/validation.ts
new file mode 100644
index 0000000000..005352e400
--- /dev/null
+++ b/apps/web/app/api/chat/validation.ts
@@ -0,0 +1,32 @@
+import { z } from "zod";
+
+const parsedMessageSchema = z.object({
+ id: z.string(),
+ threadId: z.string(),
+ snippet: z.string(),
+ textPlain: z.string().optional(),
+ textHtml: z.string().optional(),
+ headers: z.object({
+ from: z.string(),
+ to: z.string(),
+ subject: z.string(),
+ cc: z.string().optional(),
+ date: z.string(),
+ "reply-to": z.string().optional(),
+ }),
+ internalDate: z.string().optional().nullable(),
+});
+
+export const messageContextSchema = z.object({
+ type: z.literal("fix-rule"),
+ message: parsedMessageSchema,
+ results: z.array(
+ z.object({ ruleName: z.string().nullable(), reason: z.string() }),
+ ),
+ expected: z.union([
+ z.literal("new"),
+ z.literal("none"),
+ z.object({ name: z.string() }),
+ ]),
+});
+export type MessageContext = z.infer
;
diff --git a/apps/web/app/api/google/webhook/types.ts b/apps/web/app/api/google/webhook/types.ts
index 6fd1903d71..4ff63b9f61 100644
--- a/apps/web/app/api/google/webhook/types.ts
+++ b/apps/web/app/api/google/webhook/types.ts
@@ -1,5 +1,5 @@
import type { gmail_v1 } from "@googleapis/gmail";
-import type { RuleWithActionsAndCategories } from "@/utils/types";
+import type { RuleWithActions } from "@/utils/types";
import type { EmailAccountWithAI } from "@/utils/llms/types";
import type { EmailAccount } from "@prisma/client";
@@ -16,7 +16,7 @@ export type ProcessHistoryOptions = {
history: gmail_v1.Schema$History[];
gmail: gmail_v1.Gmail;
accessToken: string;
- rules: RuleWithActionsAndCategories[];
+ rules: RuleWithActions[];
hasAutomationRules: boolean;
hasAiAccess: boolean;
emailAccount: Pick &
diff --git a/apps/web/app/api/outlook/webhook/process-history-item.ts b/apps/web/app/api/outlook/webhook/process-history-item.ts
index bedb5fc0da..d851d56844 100644
--- a/apps/web/app/api/outlook/webhook/process-history-item.ts
+++ b/apps/web/app/api/outlook/webhook/process-history-item.ts
@@ -1,14 +1,14 @@
import type { OutlookResourceData } from "@/app/api/outlook/webhook/types";
import { logger as globalLogger } from "@/app/api/outlook/webhook/logger";
import { processHistoryItem as processHistoryItemShared } from "@/utils/webhook/process-history-item";
-import type { RuleWithActionsAndCategories } from "@/utils/types";
+import type { RuleWithActions } from "@/utils/types";
import type { EmailAccountWithAI } from "@/utils/llms/types";
import type { EmailAccount } from "@prisma/client";
import type { EmailProvider } from "@/utils/email/types";
type ProcessHistoryOptions = {
provider: EmailProvider;
- rules: RuleWithActionsAndCategories[];
+ rules: RuleWithActions[];
hasAutomationRules: boolean;
hasAiAccess: boolean;
emailAccount: Pick &
diff --git a/apps/web/app/api/threads/route.ts b/apps/web/app/api/threads/route.ts
index e3a6a15c7a..1a56016c2b 100644
--- a/apps/web/app/api/threads/route.ts
+++ b/apps/web/app/api/threads/route.ts
@@ -3,8 +3,6 @@ import { withEmailProvider } from "@/utils/middleware";
import { type ThreadsQuery, threadsQuery } from "@/app/api/threads/validation";
import { isDefined } from "@/utils/types";
import prisma from "@/utils/prisma";
-import { getCategory } from "@/utils/redis/category";
-import { ExecutedRuleStatus } from "@prisma/client";
import { createScopedLogger } from "@/utils/logger";
import { isIgnoredSender } from "@/utils/filter-ignored-senders";
import type { EmailProvider } from "@/utils/email/types";
@@ -109,7 +107,6 @@ async function getThreads({
messages: filteredMessages,
snippet: thread.snippet,
plan,
- category: await getCategory({ emailAccountId, threadId: thread.id }),
};
}),
);
diff --git a/apps/web/app/api/user/complete-registration/route.ts b/apps/web/app/api/user/complete-registration/route.ts
index 4963243824..60a017685e 100644
--- a/apps/web/app/api/user/complete-registration/route.ts
+++ b/apps/web/app/api/user/complete-registration/route.ts
@@ -86,10 +86,9 @@ async function storePosthogSignupEvent(userId: string, email: string) {
const ONE_HOUR_AGO = new Date(Date.now() - ONE_HOUR_MS);
if (userCreatedAt.createdAt < ONE_HOUR_AGO) {
- logger.error(
- "storePosthogSignupEvent: User created more than an hour ago",
- { userId },
- );
+ logger.warn("storePosthogSignupEvent: User created more than an hour ago", {
+ userId,
+ });
return;
}
diff --git a/apps/web/app/api/user/executed-rules/batch/route.ts b/apps/web/app/api/user/executed-rules/batch/route.ts
index ebdd5f68a5..b5ef82bb18 100644
--- a/apps/web/app/api/user/executed-rules/batch/route.ts
+++ b/apps/web/app/api/user/executed-rules/batch/route.ts
@@ -27,15 +27,19 @@ async function getData({
actionItems: true,
rule: true,
status: true,
+ createdAt: true,
},
orderBy: { id: "asc" },
});
// Convert to a map for easy lookup by messageId
- const rulesMap: Record = {};
+ const rulesMap: Record = {};
for (const executedRule of executedRules) {
- rulesMap[executedRule.messageId] = executedRule;
+ if (!rulesMap[executedRule.messageId]) {
+ rulesMap[executedRule.messageId] = [];
+ }
+ rulesMap[executedRule.messageId].push(executedRule);
}
return { rulesMap };
diff --git a/apps/web/app/api/user/planned/get-executed-rules.ts b/apps/web/app/api/user/executed-rules/history/route.ts
similarity index 60%
rename from apps/web/app/api/user/planned/get-executed-rules.ts
rename to apps/web/app/api/user/executed-rules/history/route.ts
index e4d5bbd75c..ee5ecba4f4 100644
--- a/apps/web/app/api/user/planned/get-executed-rules.ts
+++ b/apps/web/app/api/user/executed-rules/history/route.ts
@@ -1,33 +1,61 @@
+import { NextResponse } from "next/server";
+import { withEmailProvider } from "@/utils/middleware";
import { isDefined } from "@/utils/types";
import prisma from "@/utils/prisma";
-import { ExecutedRuleStatus } from "@prisma/client";
+import { ExecutedRuleStatus, type Prisma } from "@prisma/client";
import { createScopedLogger } from "@/utils/logger";
import type { EmailProvider } from "@/utils/email/types";
-const logger = createScopedLogger("api/user/planned/get-executed-rules");
-
const LIMIT = 50;
-export async function getExecutedRules({
- status,
+export const dynamic = "force-dynamic";
+
+export type GetExecutedRulesResponse = Awaited<
+ ReturnType
+>;
+
+export const GET = withEmailProvider(async (request) => {
+ const emailAccountId = request.auth.emailAccountId;
+
+ const url = new URL(request.url);
+ const page = Number.parseInt(url.searchParams.get("page") || "1");
+ const ruleId = url.searchParams.get("ruleId") || "all";
+
+ const result = await getExecutedRules({
+ page,
+ ruleId,
+ emailAccountId,
+ emailProvider: request.emailProvider,
+ });
+
+ return NextResponse.json(result);
+});
+
+async function getExecutedRules({
page,
ruleId,
emailAccountId,
emailProvider,
}: {
- status: ExecutedRuleStatus;
page: number;
ruleId?: string;
emailAccountId: string;
emailProvider: EmailProvider;
}) {
- const where = {
+ const logger = createScopedLogger("api/user/executed-rules/history").with({
+ emailAccountId,
+ ruleId,
+ });
+
+ const where: Prisma.ExecutedRuleWhereInput = {
emailAccountId,
- status: ruleId === "skipped" ? ExecutedRuleStatus.SKIPPED : status,
+ status:
+ ruleId === "skipped"
+ ? ExecutedRuleStatus.SKIPPED
+ : ExecutedRuleStatus.APPLIED,
rule: ruleId === "skipped" ? undefined : { isNot: null },
ruleId: ruleId === "all" || ruleId === "skipped" ? undefined : ruleId,
};
- logger.info("getExecutedRules query", { where });
const [executedRules, total] = await Promise.all([
prisma.executedRule.findMany({
@@ -42,7 +70,6 @@ export async function getExecutedRules({
rule: {
include: {
group: { select: { name: true } },
- categoryFilters: true,
},
},
actionItems: true,
@@ -67,8 +94,6 @@ export async function getExecutedRules({
error,
messageId: p.messageId,
threadId: p.threadId,
- emailAccountId,
- ruleId,
});
}
}),
diff --git a/apps/web/app/api/user/planned/history/route.ts b/apps/web/app/api/user/planned/history/route.ts
deleted file mode 100644
index aaa2889f9a..0000000000
--- a/apps/web/app/api/user/planned/history/route.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { NextResponse } from "next/server";
-import { withEmailProvider } from "@/utils/middleware";
-import { ExecutedRuleStatus } from "@prisma/client";
-import { getExecutedRules } from "@/app/api/user/planned/get-executed-rules";
-
-export const dynamic = "force-dynamic";
-
-export type PlanHistoryResponse = Awaited>;
-
-export const GET = withEmailProvider(async (request) => {
- const emailAccountId = request.auth.emailAccountId;
-
- const url = new URL(request.url);
- const page = Number.parseInt(url.searchParams.get("page") || "1");
- const ruleId = url.searchParams.get("ruleId") || "all";
-
- const messages = await getExecutedRules({
- status: ExecutedRuleStatus.APPLIED,
- page,
- ruleId,
- emailAccountId,
- emailProvider: request.emailProvider,
- });
-
- return NextResponse.json(messages);
-});
diff --git a/apps/web/app/api/user/rules/[id]/example/controller.ts b/apps/web/app/api/user/rules/[id]/example/controller.ts
index d387fadb7c..5cb529d9f3 100644
--- a/apps/web/app/api/user/rules/[id]/example/controller.ts
+++ b/apps/web/app/api/user/rules/[id]/example/controller.ts
@@ -7,12 +7,7 @@ import {
splitEmailPatterns,
} from "@/utils/ai/choose-rule/match-rules";
import { fetchPaginatedMessages } from "@/app/api/user/group/[groupId]/messages/controller";
-import {
- isGroupRule,
- isAIRule,
- isStaticRule,
- isCategoryRule,
-} from "@/utils/condition";
+import { isGroupRule, isAIRule, isStaticRule } from "@/utils/condition";
import { LogicalOperator } from "@prisma/client";
import type { EmailProvider } from "@/utils/email/types";
@@ -23,13 +18,12 @@ export async function fetchExampleMessages(
const isStatic = isStaticRule(rule);
const isGroup = isGroupRule(rule);
const isAI = isAIRule(rule);
- const isCategory = isCategoryRule(rule);
- if (isAI || isCategory) return [];
+ if (isAI) return [];
// if AND and more than 1 condition, return []
// TODO: handle multiple conditions properly and return real examples
- const conditions = [isStatic, isGroup, isAI, isCategory];
+ const conditions = [isStatic, isGroup, isAI];
const trueConditionsCount = conditions.filter(Boolean).length;
if (
diff --git a/apps/web/app/api/user/rules/[id]/route.ts b/apps/web/app/api/user/rules/[id]/route.ts
index 3952dcd2b9..4b336ba1a6 100644
--- a/apps/web/app/api/user/rules/[id]/route.ts
+++ b/apps/web/app/api/user/rules/[id]/route.ts
@@ -18,7 +18,6 @@ async function getRule({
where: { id: ruleId, emailAccount: { id: emailAccountId } },
include: {
actions: true,
- categoryFilters: true,
},
});
@@ -42,7 +41,6 @@ async function getRule({
folderName: { value: action.folderName },
folderId: { value: action.folderId },
})),
- categoryFilters: rule.categoryFilters.map((category) => category.id),
conditions: getConditions(rule),
};
diff --git a/apps/web/app/api/user/rules/route.ts b/apps/web/app/api/user/rules/route.ts
index 7d2bae0e9a..49c18fb37c 100644
--- a/apps/web/app/api/user/rules/route.ts
+++ b/apps/web/app/api/user/rules/route.ts
@@ -10,7 +10,6 @@ async function getRules({ emailAccountId }: { emailAccountId: string }) {
include: {
actions: true,
group: { select: { name: true } },
- categoryFilters: { select: { id: true, name: true } },
},
orderBy: { createdAt: "asc" },
});
diff --git a/apps/web/components/CategoryBadge.tsx b/apps/web/components/CategoryBadge.tsx
deleted file mode 100644
index 2a50299467..0000000000
--- a/apps/web/components/CategoryBadge.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { Badge, type Color } from "@/components/Badge";
-import { capitalCase } from "capital-case";
-
-const categoryColors: Record = {
- NEWSLETTER: "blue",
- PROMOTIONAL: "yellow",
- RECEIPT: "yellow",
- ALERT: "yellow",
- NOTIFICATION: "yellow",
- FORUM: "yellow",
- EVENT: "green",
- TRAVEL: "green",
- QUESTION: "red",
- SUPPORT: "red",
- COLD_EMAIL: "yellow",
- SOCIAL_MEDIA: "yellow",
- LEGAL_UPDATE: "yellow",
- OTHER: "yellow",
-};
-
-export function CategoryBadge(props: { category?: string }) {
- const { category } = props;
-
- return (
-
- {capitalCase(category || "Uncategorized")}
-
- );
-}
diff --git a/apps/web/components/ExpandableText.tsx b/apps/web/components/ExpandableText.tsx
index 28c9b34123..b1d98c9db9 100644
--- a/apps/web/components/ExpandableText.tsx
+++ b/apps/web/components/ExpandableText.tsx
@@ -2,7 +2,7 @@
import { useState } from "react";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
-import { motion } from "framer-motion";
+import { motion } from "motion/react";
import { cn } from "@/utils";
export function ExpandableText({
diff --git a/apps/web/components/GroupedTable.tsx b/apps/web/components/GroupedTable.tsx
index 169ffd8811..2d0274eca3 100644
--- a/apps/web/components/GroupedTable.tsx
+++ b/apps/web/components/GroupedTable.tsx
@@ -16,12 +16,9 @@ import {
ChevronRight,
MoreVerticalIcon,
PencilIcon,
- FileCogIcon,
- PlusIcon,
BookmarkXIcon,
} from "lucide-react";
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
-import { ConditionType } from "@/utils/config";
import { EmailCell } from "@/components/EmailCell";
import { useThreads } from "@/hooks/useThreads";
import { Skeleton } from "@/components/ui/skeleton";
@@ -58,7 +55,6 @@ import type { CategoryWithRules } from "@/utils/category.server";
import { ViewEmailButton } from "@/components/ViewEmailButton";
import { CategorySelect } from "@/components/CategorySelect";
import { useAccount } from "@/providers/EmailAccountProvider";
-import { prefixPath } from "@/utils/path";
const COLUMNS = 4;
@@ -247,7 +243,6 @@ export function GroupedTable({
return (
- {category.rules.length ? (
-
- {category.rules.map((rule) => (
-
-
-
- {rule.name || `Rule ${rule.id}`}
-
-
- ))}
-
- ) : (
-
-
-
- Attach rule
-
-
- )}
Archive all
diff --git a/apps/web/components/ProgressPanel.tsx b/apps/web/components/ProgressPanel.tsx
index e4485aeb95..75fc327d97 100644
--- a/apps/web/components/ProgressPanel.tsx
+++ b/apps/web/components/ProgressPanel.tsx
@@ -1,6 +1,6 @@
"use client";
-import { AnimatePresence, motion } from "framer-motion";
+import { AnimatePresence, motion } from "motion/react";
import { ProgressBar } from "@tremor/react";
import { cn } from "@/utils";
import { LoadingMiniSpinner } from "@/components/Loading";
diff --git a/apps/web/components/TabSelect.tsx b/apps/web/components/TabSelect.tsx
index f6a315b219..fc0f9d040e 100644
--- a/apps/web/components/TabSelect.tsx
+++ b/apps/web/components/TabSelect.tsx
@@ -10,7 +10,7 @@
*/
import { cn } from "@/utils";
import { cva, type VariantProps } from "class-variance-authority";
-import { LayoutGroup, motion } from "framer-motion";
+import { LayoutGroup, motion } from "motion/react";
import Link from "next/link";
import { type Dispatch, type SetStateAction, useId } from "react";
import { ArrowUpRight } from "lucide-react";
diff --git a/apps/web/components/ai-elements/actions.tsx b/apps/web/components/ai-elements/actions.tsx
index 76fbfeeee5..1d189e8a89 100644
--- a/apps/web/components/ai-elements/actions.tsx
+++ b/apps/web/components/ai-elements/actions.tsx
@@ -7,7 +7,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { cn } from "@/utils";
+import { cn } from "@/utils/index";
import type { ComponentProps } from "react";
export type ActionsProps = ComponentProps<"div">;
@@ -35,7 +35,7 @@ export const Action = ({
const button = (
);
+export type ConversationEmptyStateProps = ComponentProps<"div"> & {
+ title?: string;
+ description?: string;
+ icon?: React.ReactNode;
+};
+
+export const ConversationEmptyState = ({
+ className,
+ title = "No messages yet",
+ description = "Start a conversation to see messages here",
+ icon,
+ children,
+ ...props
+}: ConversationEmptyStateProps) => (
+
+ {children ?? (
+ <>
+ {icon &&
{icon}
}
+
+
{title}
+ {description && (
+
{description}
+ )}
+
+ >
+ )}
+
+);
+
export type ConversationScrollButtonProps = ComponentProps;
export const ConversationScrollButton = ({
diff --git a/apps/web/components/ai-elements/loader.tsx b/apps/web/components/ai-elements/loader.tsx
index 5791d8ddcb..87960fd1e9 100644
--- a/apps/web/components/ai-elements/loader.tsx
+++ b/apps/web/components/ai-elements/loader.tsx
@@ -1,4 +1,4 @@
-import { cn } from "@/utils";
+import { cn } from "@/utils/index";
import type { HTMLAttributes } from "react";
type LoaderIconProps = {
diff --git a/apps/web/components/ai-elements/message.tsx b/apps/web/components/ai-elements/message.tsx
index 55a27ae111..89f117b21f 100644
--- a/apps/web/components/ai-elements/message.tsx
+++ b/apps/web/components/ai-elements/message.tsx
@@ -1,5 +1,5 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
-import { cn } from "@/utils";
+import { cn } from "@/utils/index";
import type { UIMessage } from "ai";
import { cva, type VariantProps } from "class-variance-authority";
import type { ComponentProps, HTMLAttributes } from "react";
diff --git a/apps/web/components/ai-elements/reasoning.tsx b/apps/web/components/ai-elements/reasoning.tsx
index 51df275a9f..2c91891809 100644
--- a/apps/web/components/ai-elements/reasoning.tsx
+++ b/apps/web/components/ai-elements/reasoning.tsx
@@ -6,17 +6,18 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
-import { cn } from "@/utils";
+import { cn } from "@/utils/index";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react";
import { Response } from "./response";
+import { Shimmer } from "./shimmer";
type ReasoningContextValue = {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
- duration: number | undefined;
+ duration: number;
};
const ReasoningContext = createContext(null);
@@ -58,7 +59,7 @@ export const Reasoning = memo(
});
const [duration, setDuration] = useControllableState({
prop: durationProp,
- defaultProp: undefined,
+ defaultProp: 0,
});
const [hasAutoClosed, setHasAutoClosed] = useState(false);
@@ -114,7 +115,7 @@ export type ReasoningTriggerProps = ComponentProps;
const getThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
- return Thinking...
;
+ return Thinking...;
}
if (duration === undefined) {
return Thought for a few seconds
;
diff --git a/apps/web/components/ai-elements/response.tsx b/apps/web/components/ai-elements/response.tsx
index 24929dbed1..344b4d510b 100644
--- a/apps/web/components/ai-elements/response.tsx
+++ b/apps/web/components/ai-elements/response.tsx
@@ -1,6 +1,6 @@
"use client";
-import { cn } from "@/utils";
+import { cn } from "@/utils/index";
import { type ComponentProps, memo } from "react";
import { Streamdown } from "streamdown";
diff --git a/apps/web/components/ai-elements/shimmer.tsx b/apps/web/components/ai-elements/shimmer.tsx
new file mode 100644
index 0000000000..aedcca10c5
--- /dev/null
+++ b/apps/web/components/ai-elements/shimmer.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { cn } from "@/utils/index";
+import { motion } from "motion/react";
+import {
+ type CSSProperties,
+ type ElementType,
+ type JSX,
+ memo,
+ useMemo,
+} from "react";
+
+export type TextShimmerProps = {
+ children: string;
+ as?: ElementType;
+ className?: string;
+ duration?: number;
+ spread?: number;
+};
+
+const ShimmerComponent = ({
+ children,
+ as: Component = "p",
+ className,
+ duration = 2,
+ spread = 2,
+}: TextShimmerProps) => {
+ const MotionComponent = motion.create(
+ Component as keyof JSX.IntrinsicElements,
+ );
+
+ const dynamicSpread = useMemo(
+ () => (children?.length ?? 0) * spread,
+ [children, spread],
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const Shimmer = memo(ShimmerComponent);
diff --git a/apps/web/components/ai-elements/suggestion.tsx b/apps/web/components/ai-elements/suggestion.tsx
index 8037addb82..3c6522ec19 100644
--- a/apps/web/components/ai-elements/suggestion.tsx
+++ b/apps/web/components/ai-elements/suggestion.tsx
@@ -2,7 +2,7 @@
import { Button } from "@/components/ui/button";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
-import { cn } from "@/utils";
+import { cn } from "@/utils/index";
import type { ComponentProps } from "react";
export type SuggestionsProps = ComponentProps;
diff --git a/apps/web/components/ai-elements/tool.tsx b/apps/web/components/ai-elements/tool.tsx
index 3aa963956e..12af7096ca 100644
--- a/apps/web/components/ai-elements/tool.tsx
+++ b/apps/web/components/ai-elements/tool.tsx
@@ -6,7 +6,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
-import { cn } from "@/utils";
+import { cn } from "@/utils/index";
import type { ToolUIPart } from "ai";
import {
CheckCircleIcon,
@@ -17,6 +17,7 @@ import {
XCircleIcon,
} from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
+import { isValidElement } from "react";
import { CodeBlock } from "./code-block";
export type ToolProps = ComponentProps;
@@ -29,6 +30,7 @@ export const Tool = ({ className, ...props }: ToolProps) => (
);
export type ToolHeaderProps = {
+ title?: string;
type: ToolUIPart["type"];
state: ToolUIPart["state"];
className?: string;
@@ -50,7 +52,7 @@ const getStatusBadge = (status: ToolUIPart["state"]) => {
} as const;
return (
-
+
{icons[status]}
{labels[status]}
@@ -59,6 +61,7 @@ const getStatusBadge = (status: ToolUIPart["state"]) => {
export const ToolHeader = ({
className,
+ title,
type,
state,
...props
@@ -72,7 +75,9 @@ export const ToolHeader = ({
>
- {type}
+
+ {title ?? type.split("-").slice(1).join("-")}
+
{getStatusBadge(state)}
@@ -107,7 +112,7 @@ export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
);
export type ToolOutputProps = ComponentProps<"div"> & {
- output: ReactNode;
+ output: ToolUIPart["output"];
errorText: ToolUIPart["errorText"];
};
@@ -121,6 +126,16 @@ export const ToolOutput = ({
return null;
}
+ let Output = {output as ReactNode}
;
+
+ if (typeof output === "object" && !isValidElement(output)) {
+ Output = (
+
+ );
+ } else if (typeof output === "string") {
+ Output = ;
+ }
+
return (
@@ -135,7 +150,7 @@ export const ToolOutput = ({
)}
>
{errorText &&
{errorText}
}
- {output && {output}
}
+ {Output}
);
diff --git a/apps/web/components/assistant-chat/chat.tsx b/apps/web/components/assistant-chat/chat.tsx
index 7763a057c0..1c9545e159 100644
--- a/apps/web/components/assistant-chat/chat.tsx
+++ b/apps/web/components/assistant-chat/chat.tsx
@@ -26,7 +26,16 @@ import { useLocalStorage } from "usehooks-ts";
const MAX_MESSAGES = 20;
export function Chat() {
- const { chat, chatId, input, setInput, handleSubmit, setNewChat } = useChat();
+ const {
+ chat,
+ chatId,
+ input,
+ setInput,
+ handleSubmit,
+ setNewChat,
+ context,
+ setContext,
+ } = useChat();
const { messages, status, stop, regenerate, setMessages } = chat;
const [localStorageInput, setLocalStorageInput] = useLocalStorage(
"input",
@@ -81,6 +90,22 @@ export function Chat() {
/>