Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
7ceee97
wip: move to multi rule system
elie222 Oct 19, 2025
bb46440
fix build
elie222 Oct 19, 2025
feeffff
fix multi select prompt
elie222 Oct 19, 2025
6e44e44
Merge branch 'main' into feat/multi-rule
elie222 Oct 19, 2025
1f1dc72
improve filter
elie222 Oct 19, 2025
4441c91
warn
elie222 Oct 19, 2025
c284968
get no match reasoning
elie222 Oct 19, 2025
bd619a3
normalize cold email from
elie222 Oct 19, 2025
e4cc187
fix cancel scheduled action
elie222 Oct 19, 2025
f0bd009
fix context loader bug
elie222 Oct 19, 2025
c1808fe
Remove categories from rules
elie222 Oct 20, 2025
8cfd43c
fix up match rules to work with multiple rules
elie222 Oct 20, 2025
9dcca89
reorder file
elie222 Oct 20, 2025
b046492
Fix tests
elie222 Oct 20, 2025
cd3713d
increase test coverage for match rules
elie222 Oct 20, 2025
d11cc96
more match rule tests
elie222 Oct 20, 2025
f4d6b61
delete more old category code
elie222 Oct 20, 2025
a9b335c
fixes
elie222 Oct 20, 2025
28b82f0
use isprimary to only choose one system rule
elie222 Oct 20, 2025
98b009d
ensure conversation tracking continues
elie222 Oct 20, 2025
db35d00
tests
elie222 Oct 20, 2025
7a332dc
allow applying previously applied rules on threads
elie222 Oct 20, 2025
fdfe9e6
add more tests for match rules
elie222 Oct 20, 2025
121d762
update ui components
elie222 Oct 20, 2025
f124aa1
dont store executed rule on test
elie222 Oct 20, 2025
20e87ee
fix comment
elie222 Oct 20, 2025
2fcf906
fix comment
elie222 Oct 20, 2025
49b17ba
fix comment
elie222 Oct 20, 2025
b2239d2
dont load folders for non microsoft
elie222 Oct 20, 2025
4fab403
revert deletion of condition summary card
elie222 Oct 20, 2025
3f5c6b1
group batches by date in the ui
elie222 Oct 20, 2025
19f92be
message
elie222 Oct 20, 2025
fb2636f
simplify chat fix by sending context as tag
elie222 Oct 21, 2025
ebc9202
fix ai tests
elie222 Oct 21, 2025
cf18bde
update sort and tests
elie222 Oct 21, 2025
d6fe48a
fix history executed at
elie222 Oct 21, 2025
bb89ecc
fixes
elie222 Oct 21, 2025
9c9a848
rename files
elie222 Oct 21, 2025
229ab4b
rename var
elie222 Oct 21, 2025
2048c4c
Add migration
elie222 Oct 21, 2025
8a648e6
fix skipped
elie222 Oct 21, 2025
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
297 changes: 217 additions & 80 deletions apps/web/__tests__/ai-choose-rule.test.ts

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions apps/web/__tests__/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,14 @@ export function getEmail({
};
}

export function getRule(instructions: string, actions: Action[] = []) {
export function getRule(
instructions: string,
actions: Action[] = [],
name?: string,
) {
return {
instructions,
name: "Joke requests",
name: name || "Joke requests",
actions,
id: "id",
userId: "userId",
Expand All @@ -57,7 +61,6 @@ export function getRule(instructions: string, actions: Action[] = []) {
body: null,
to: null,
enabled: true,
categoryFilterType: null,
conditionalOperator: LogicalOperator.AND,
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
import { BotIcon, FilterIcon } from "lucide-react";
import { capitalCase } from "capital-case";
import type { CreateRuleBody } from "@/utils/actions/rule.validation";
import { ConditionType } from "@/utils/config";
import { CardBasic } from "@/components/ui/card";
import { CategoryFilterType } from "@prisma/client";

export function ConditionSummaryCard({
condition,
categories,
}: {
condition: CreateRuleBody["conditions"][number];
categories?: Array<{ id: string; name: string }>;
}) {
let summaryContent: React.ReactNode = condition.type;
let Icon = FilterIcon;
Expand Down Expand Up @@ -57,36 +53,6 @@ export function ConditionSummaryCard({
break;
}

case ConditionType.CATEGORY: {
textColorClass = "text-green-500";
const filterType =
condition.categoryFilterType || CategoryFilterType.INCLUDE;
const categoryFilters = condition.categoryFilters || [];

if (categoryFilters.length > 0 && categories && categories.length > 0) {
const categoryNames = categoryFilters
.map((id) => {
const category = categories.find((cat) => cat.id === id);
return category ? capitalCase(category.name) : null;
})
.filter(Boolean)
.join(", ");

summaryContent = (
<>
<span>Category Condition</span>
<span className="mt-2 block text-muted-foreground">
{filterType === CategoryFilterType.INCLUDE ? "Match" : "Skip"}{" "}
categories: {categoryNames || "Unknown"}
</span>
</>
);
} else {
summaryContent = "Category Condition (no categories selected)";
}
break;
}

default:
summaryContent = `${condition.type} Condition`;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Link from "next/link";
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 | 🔴 Critical

This file uses hooks and onClick; mark as a client component.

Without "use client", Next.js will error at runtime/build.

+"use client";
+
 import Link from "next/link";
📝 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
import Link from "next/link";
"use client";
import Link from "next/link";
🤖 Prompt for AI Agents
In apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx at line
1, the component uses hooks and onClick but lacks the Next.js client directive;
add the exact string "use client" as the very first line of the file (before any
imports) so the module is treated as a client component, then save and rebuild
to ensure Next.js recognizes it as client-side.

import { ExternalLinkIcon, EyeIcon } from "lucide-react";
import type { PlanHistoryResponse } from "@/app/api/user/planned/history/route";
import type { GetExecutedRulesResponse } from "@/app/api/user/executed-rules/history/route";
import { decodeSnippet } from "@/utils/gmail/decode";
import { ActionBadgeExpanded } from "@/components/PlanBadge";
import { Tooltip } from "@/components/Tooltip";
Expand Down Expand Up @@ -63,12 +63,14 @@ export function EmailCell({

export function RuleCell({
rule,
executedAt,
status,
reason,
message,
setInput,
}: {
rule: PlanHistoryResponse["executedRules"][number]["rule"];
rule: GetExecutedRulesResponse["executedRules"][number]["rule"];
executedAt: Date;
status: ExecutedRuleStatus;
reason?: string | null;
message: ParsedMessage;
Expand Down Expand Up @@ -136,7 +138,7 @@ export function RuleCell({
<FixWithChat
setInput={setInput}
message={message}
result={{ rule, reason }}
results={[{ rule, reason, createdAt: executedAt }]}
/>
<RuleDialogComponent />
</div>
Expand All @@ -147,7 +149,7 @@ export function ActionItemsCell({
actionItems,
provider,
}: {
actionItems: PlanHistoryResponse["executedRules"][number]["actionItems"];
actionItems: GetExecutedRulesResponse["executedRules"][number]["actionItems"];
provider: string;
}) {
return (
Expand Down
132 changes: 68 additions & 64 deletions apps/web/app/(app)/[emailAccountId]/assistant/FixWithChat.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { MessageCircleIcon } from "lucide-react";
import { useState } from "react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import type { ParsedMessage } from "@/utils/types";
import type { RunRulesResult } from "@/utils/ai/choose-rule/run-rules";
import { truncate } from "@/utils/string";
import {
Dialog,
DialogContent,
Expand All @@ -23,15 +22,21 @@ import { NONE_RULE_ID } from "@/app/(app)/[emailAccountId]/assistant/consts";
import { useSidebar } from "@/components/ui/sidebar";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { useChat } from "@/providers/ChatProvider";
import {
NEW_RULE_ID as CONST_NEW_RULE_ID,
NONE_RULE_ID as CONST_NONE_RULE_ID,
} from "@/app/(app)/[emailAccountId]/assistant/consts";
import type { MessageContext } from "@/app/api/chat/validation";

export function FixWithChat({
setInput,
message,
result,
results,
}: {
setInput: (input: string) => void;
message: ParsedMessage;
result: RunRulesResult | null;
results: RunRulesResult[];
}) {
const { data, isLoading, error } = useRules();
const { isModalOpen, setIsModalOpen } = useModal();
Expand All @@ -40,6 +45,14 @@ export function FixWithChat({
const [showExplanation, setShowExplanation] = useState(false);

const { setOpen } = useSidebar();
const { setContext } = useChat();

const selectedRuleName = useMemo(() => {
if (!data) return null;
if (selectedRuleId === NEW_RULE_ID) return "New rule";
if (selectedRuleId === NONE_RULE_ID) return "None";
return data.find((r) => r.id === selectedRuleId)?.name ?? null;
}, [data, selectedRuleId]);

const handleRuleSelect = (ruleId: string | null) => {
setSelectedRuleId(ruleId);
Expand All @@ -50,24 +63,55 @@ export function FixWithChat({
if (!selectedRuleId) return;

let input: string;
if (selectedRuleId === NEW_RULE_ID) {
input = getFixMessage({
message,
result,
expectedRuleName: NEW_RULE_ID,
explanation,
});

if (selectedRuleId === CONST_NEW_RULE_ID) {
input = explanation?.trim()
? `Create a new rule for emails like this: ${explanation.trim()}`
: "Create a new rule for emails like this: ";
} else if (selectedRuleId === CONST_NONE_RULE_ID) {
input = explanation?.trim()
? `This email shouldn't have matched any rule because ${explanation.trim()}`
: "This email shouldn't have matched any rule because ";
} else {
const expectedRule = data?.find((rule) => rule.id === selectedRuleId);

input = getFixMessage({
message,
result,
expectedRuleName: expectedRule?.name ?? null,
explanation,
});
const rulePart = selectedRuleName
? `the "${selectedRuleName}" rule`
: "a different rule";
input = explanation?.trim()
? `This email should have matched ${rulePart} because ${explanation.trim()}`
: `This email should have matched ${rulePart} because `;
}

const context: MessageContext = {
type: "fix-rule",
message: {
id: message.id,
threadId: message.threadId,
snippet: message.snippet,
textPlain: message.textPlain,
textHtml: message.textHtml,
headers: {
from: message.headers.from,
to: message.headers.to,
subject: message.headers.subject,
cc: message.headers.cc,
date: message.headers.date,
"reply-to": message.headers["reply-to"],
},
internalDate: message.internalDate,
},
results: results.map((r) => ({
ruleName: r.rule?.name ?? null,
reason: r.reason ?? "",
})),
expected:
selectedRuleId === CONST_NEW_RULE_ID
? "new"
: selectedRuleId === CONST_NONE_RULE_ID
? "none"
: { name: selectedRuleName || "Unknown" },
};
setContext(context);

setInput(input);
setOpen((arr) => [...arr, "chat-sidebar"]);
setIsModalOpen(false);
Expand Down Expand Up @@ -105,7 +149,7 @@ export function FixWithChat({
<LoadingContent loading={isLoading} error={error}>
{data && !showExplanation ? (
<RuleMismatch
result={result}
results={results}
rules={data}
onSelectExpectedRuleId={handleRuleSelect}
/>
Expand Down Expand Up @@ -165,61 +209,21 @@ export function FixWithChat({
);
}

function getFixMessage({
message,
result,
expectedRuleName,
explanation,
}: {
message: ParsedMessage;
result: RunRulesResult | null;
expectedRuleName: string | null;
explanation?: string;
}) {
// Truncate content if it's too long
// TODO: HTML text / text plain
const getMessageContent = () => {
const content = message.snippet || message.textPlain || "";
return truncate(content, 500).trim();
};

return `You applied the wrong rule to this email.
Fix our rules so this type of email is handled correctly in the future.

Email details:
*From*: ${message.headers.from}
*Subject*: ${message.headers.subject}
*Content*: ${getMessageContent()}

Current rule applied: ${result?.rule?.name || "No rule"}

Reason the rule was chosen:
${result?.reason || "-"}

${
expectedRuleName === NEW_RULE_ID
? "I'd like to create a new rule to handle this type of email."
: expectedRuleName
? `The rule that should have been applied was: "${expectedRuleName}"`
: "Instead, no rule should have been applied."
}${explanation ? `\n\nExplanation: ${explanation}` : ""}`.trim();
}

function RuleMismatch({
result,
results,
rules,
onSelectExpectedRuleId,
}: {
result: RunRulesResult | null;
results: RunRulesResult[];
rules: RulesResponse;
onSelectExpectedRuleId: (ruleId: string | null) => void;
}) {
return (
<div>
<Label name="matchedRule" label="Matched:" />
<div className="mt-1">
{result ? (
<ProcessResultDisplay result={result} />
{results.length > 0 ? (
<ProcessResultDisplay results={results} />
) : (
<p>No rule matched</p>
)}
Expand Down
37 changes: 18 additions & 19 deletions apps/web/app/(app)/[emailAccountId]/assistant/History.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<PlanHistoryResponse>(
`/api/user/planned/history?page=${page}&ruleId=${ruleId}`,
);
const { data, isLoading, error } = useExecutedRules({ page, ruleId });

return (
<>
Expand Down Expand Up @@ -62,7 +60,7 @@ function HistoryTable({
data,
totalPages,
}: {
data: PlanHistoryResponse["executedRules"];
data: GetExecutedRulesResponse["executedRules"];
totalPages: number;
}) {
const { userEmail } = useAccount();
Expand All @@ -78,30 +76,31 @@ function HistoryTable({
</TableRow>
</TableHeader>
<TableBody>
{data.map((p) => (
<TableRow key={p.id}>
{data.map((er) => (
<TableRow key={er.id}>
<TableCell>
<EmailCell
from={p.message.headers.from}
subject={p.message.headers.subject}
snippet={p.message.snippet}
threadId={p.message.threadId}
messageId={p.message.id}
from={er.message.headers.from}
subject={er.message.headers.subject}
snippet={er.message.snippet}
threadId={er.message.threadId}
messageId={er.message.id}
userEmail={userEmail}
createdAt={p.createdAt}
createdAt={er.createdAt}
/>
{!p.automated && (
{!er.automated && (
<Badge color="yellow" className="mt-2">
Applied manually
</Badge>
)}
</TableCell>
<TableCell>
<RuleCell
rule={p.rule}
status={p.status}
reason={p.reason}
message={p.message}
rule={er.rule}
executedAt={er.createdAt}
status={er.status}
reason={er.reason}
message={er.message}
setInput={setInput}
/>
{/* <ActionItemsCell actionItems={p.actionItems} /> */}
Expand Down
Loading
Loading