Skip to content

Commit d077bab

Browse files
authored
feat: implement comprehensive agent macros system (#568)
🎯 Closes #529 - Implement Saved Replies & E2E Testing ### Problem Customer support staff needed a way to manage and use saved replies (macros) to improve response times and consistency. The project also lacked comprehensive E2E tests, making it difficult to catch regressions and deploy new features with confidence. ### Solution Implemented a comprehensive **Saved Replies** feature and a robust **Playwright E2E testing framework** with the following capabilities: **✨ Create & Manage Saved Replies** - Create, edit, and delete saved replies with rich content - Quick access copy functionality from the main view - Search saved replies by name or content - Analytics tracking for usage count on each reply **🧪 E2E Playwright Testing** - **4 test suites** now in total covering authentication, conversations, and saved replies - **35+ comprehensive tests** for all critical user journeys - **CI-friendly setup** with proper environment handling ### Key Benefits - **Faster Response Times**: Agents can create and insert pre-written, high-quality responses - **Consistency**: Ensures uniform messaging across the support team - **Reliable Releases**: E2E tests prevent regressions and ensure high quality - **Better Developer Experience**: Faster, more reliable testing workflow ### User Experience --- **Empty State** <img width="1323" alt="Screenshot 2025-06-27 at 8 46 00 AM" src="https://github.com/user-attachments/assets/62287f67-b3c0-463f-b310-a2874574475f" /> --- **Saved Replies List** <img width="1318" alt="Screenshot 2025-06-27 at 8 48 42 AM" src="https://github.com/user-attachments/assets/ff10d9cc-7822-47ed-9554-ca362978f856" /> --- **Edit Modal with Delete** <img width="681" alt="Screenshot 2025-06-27 at 8 47 23 AM" src="https://github.com/user-attachments/assets/25e91f9c-a474-4596-8509-9f9e6769038b" /> ### Technical Implementation - **Full CRUD operations** for saved replies with tRPC API endpoints - **PostgreSQL schema** with usage tracking and cascade delete - **E2E testing framework** with Playwright and Page Object Model - **Cleaned up unnecessary scripts** and debugging artifacts ### Testing Instructions 1. **Start the development environment:** `pnpm dev` 2. **Navigate to Saved Replies**: `/mailboxes/[mailbox-slug]/saved-replies` 3. **Run E2E tests:** `pnpm test:e2e:clean` (starts with clean DB) 4. **Test with existing data**: `pnpm test:e2e:with-data` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced "Saved Replies" management, allowing users to create, edit, delete, search, and copy reusable replies within each mailbox. * Added a sidebar link and dedicated page for managing saved replies. * Integrated saved replies into the ticket command bar for quick insertion into conversations. * Added usage tracking for saved replies. * **UI Components** * Added new alert dialog, form, and scroll area components for improved user interactions and accessibility. * **Bug Fixes** * Expanded command bar search to include reply shortcuts and descriptions. * **Tests** * Added comprehensive end-to-end tests for saved replies, covering CRUD operations, search, clipboard, and UI states. * **Chores** * Updated scripts and dependencies to support new features and testing workflows. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 476133a commit d077bab

24 files changed

Lines changed: 6058 additions & 15 deletions

File tree

app/(dashboard)/mailboxes/[mailbox_slug]/[category]/ticketCommandBar/index.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export function TicketCommandBar({ open, onOpenChange, onInsertReply, onToggleCc
6565
setSelectedItemId,
6666
onToggleCc,
6767
setSelectedTool,
68+
onInsertReply,
6869
});
6970

7071
const previousRepliesGroups = usePreviousRepliesPage({
@@ -101,7 +102,16 @@ export function TicketCommandBar({ open, onOpenChange, onInsertReply, onToggleCc
101102
const visibleGroups = currentGroups
102103
.map((group) => ({
103104
...group,
104-
items: group.items.filter((item) => !item.hidden && item.label.toLowerCase().includes(inputValue.toLowerCase())),
105+
items: group.items.filter((item) => {
106+
if (item.hidden) return false;
107+
108+
const searchTerm = inputValue.toLowerCase();
109+
const matchesLabel = item.label.toLowerCase().includes(searchTerm);
110+
const matchesShortcut = item.shortcut?.toLowerCase().includes(searchTerm);
111+
const matchesDescription = item.description?.toLowerCase().includes(searchTerm);
112+
113+
return matchesLabel || matchesShortcut || matchesDescription;
114+
}),
105115
}))
106116
.filter((group) => group.items.length > 0);
107117
const visibleItems = visibleGroups.flatMap((group) => group.items);

app/(dashboard)/mailboxes/[mailbox_slug]/[category]/ticketCommandBar/mainPage.tsx

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@ import {
55
Mail as EnvelopeIcon,
66
PenSquare as PencilSquareIcon,
77
Play as PlayIcon,
8+
MessageSquareText as SavedReplyIcon,
89
ShieldAlert as ShieldExclamationIcon,
910
Sparkles as SparklesIcon,
1011
User as UserIcon,
1112
} from "lucide-react";
12-
import { useMemo, useRef } from "react";
13+
import { useCallback, useMemo, useRef } from "react";
1314
import { useConversationContext } from "@/app/(dashboard)/mailboxes/[mailbox_slug]/[category]/conversation/conversationContext";
1415
import { Tool } from "@/app/(dashboard)/mailboxes/[mailbox_slug]/[category]/ticketCommandBar/toolForm";
1516
import { toast } from "@/components/hooks/use-toast";
1617
import useKeyboardShortcut from "@/components/useKeyboardShortcut";
18+
import { captureExceptionAndLog } from "@/lib/shared/sentry";
1719
import { api } from "@/trpc/react";
1820
import GitHubSvg from "../icons/github.svg";
1921
import { CommandGroup } from "./types";
@@ -24,6 +26,7 @@ type MainPageProps = {
2426
setSelectedItemId: (id: string | null) => void;
2527
onToggleCc: () => void;
2628
setSelectedTool: (tool: Tool) => void;
29+
onInsertReply: (content: string) => void;
2730
};
2831

2932
export const useMainPage = ({
@@ -32,6 +35,7 @@ export const useMainPage = ({
3235
setSelectedItemId,
3336
onToggleCc,
3437
setSelectedTool,
38+
onInsertReply,
3539
}: MainPageProps): CommandGroup[] => {
3640
const { data: conversation, updateStatus, mailboxSlug, conversationSlug } = useConversationContext();
3741
const utils = api.useUtils();
@@ -71,6 +75,13 @@ export const useMainPage = ({
7175
{ staleTime: Infinity, refetchOnMount: false, refetchOnWindowFocus: false, enabled: !!conversationSlug },
7276
);
7377

78+
const { data: savedReplies } = api.mailbox.savedReplies.list.useQuery(
79+
{ mailboxSlug, onlyActive: true },
80+
{ refetchOnWindowFocus: false, refetchOnMount: true },
81+
);
82+
83+
const { mutate: incrementSavedReplyUsage } = api.mailbox.savedReplies.incrementUsage.useMutation();
84+
7485
const { data: mailbox } = api.mailbox.get.useQuery(
7586
{ mailboxSlug },
7687
{ staleTime: Infinity, refetchOnMount: false, refetchOnWindowFocus: false, enabled: !!mailboxSlug },
@@ -85,6 +96,42 @@ export const useMainPage = ({
8596
setSelectedItemId(null);
8697
});
8798

99+
const handleSavedReplySelect = useCallback(
100+
(savedReply: { slug: string; content: string }) => {
101+
try {
102+
if (!onInsertReply) {
103+
throw new Error("onInsertReply function is not available");
104+
}
105+
106+
onInsertReply(savedReply.content);
107+
onOpenChange(false);
108+
109+
// Track usage separately - don't fail the insertion if tracking fails
110+
incrementSavedReplyUsage(
111+
{ slug: savedReply.slug, mailboxSlug },
112+
{
113+
onError: (error) => {
114+
// Log tracking error but don't show to user since content was inserted successfully
115+
captureExceptionAndLog("Failed to track saved reply usage:", error);
116+
},
117+
},
118+
);
119+
} catch (error) {
120+
captureExceptionAndLog("Failed to insert saved reply content", {
121+
extra: {
122+
error,
123+
},
124+
});
125+
toast({
126+
variant: "destructive",
127+
title: "Failed to insert saved reply",
128+
description: "Could not insert the saved reply content. Please try again.",
129+
});
130+
}
131+
},
132+
[onInsertReply, incrementSavedReplyUsage, onOpenChange],
133+
);
134+
88135
const mainCommandGroups = useMemo(
89136
() => [
90137
{
@@ -190,6 +237,19 @@ export const useMainPage = ({
190237
},
191238
],
192239
},
240+
...(savedReplies && savedReplies.length > 0
241+
? [
242+
{
243+
heading: "Saved replies",
244+
items: savedReplies.slice(0, 10).map((savedReply) => ({
245+
id: savedReply.slug,
246+
label: savedReply.name,
247+
icon: SavedReplyIcon,
248+
onSelect: () => handleSavedReplySelect(savedReply),
249+
})),
250+
},
251+
]
252+
: []),
193253
...(tools && tools.all.length > 0
194254
? [
195255
{
@@ -204,7 +264,7 @@ export const useMainPage = ({
204264
]
205265
: []),
206266
],
207-
[onOpenChange, conversation, tools?.suggested, onToggleCc, isGitHubConnected],
267+
[onOpenChange, conversation, tools?.suggested, onToggleCc, isGitHubConnected, savedReplies, handleSavedReplySelect],
208268
);
209269

210270
return mainCommandGroups;

app/(dashboard)/mailboxes/[mailbox_slug]/[category]/ticketCommandBar/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ type CommandItem = {
44
icon: React.ComponentType<{ className?: string }>;
55
onSelect: () => void;
66
shortcut?: string;
7+
description?: string;
78
hidden?: boolean;
89
};
910

app/(dashboard)/mailboxes/[mailbox_slug]/appSidebar.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ChevronLeft,
99
Inbox,
1010
Link as LinkIcon,
11+
MessageSquareText,
1112
MonitorSmartphone,
1213
Settings as SettingsIcon,
1314
Ticket,
@@ -202,6 +203,22 @@ export function AppSidebar({ mailboxSlug }: { mailboxSlug: string }) {
202203
</SidebarMenuItem>
203204
</SidebarMenu>
204205
</SidebarGroup>
206+
<SidebarGroup>
207+
<SidebarMenu>
208+
<SidebarMenuItem>
209+
<SidebarMenuButton
210+
asChild
211+
isActive={pathname === `/mailboxes/${mailboxSlug}/saved-replies`}
212+
tooltip="Saved replies"
213+
>
214+
<Link href={`/mailboxes/${mailboxSlug}/saved-replies`}>
215+
<MessageSquareText className="size-4" />
216+
<span className="group-data-[collapsible=icon]:hidden">Saved replies</span>
217+
</Link>
218+
</SidebarMenuButton>
219+
</SidebarMenuItem>
220+
</SidebarMenu>
221+
</SidebarGroup>
205222
</div>
206223
<div className="mt-auto">
207224
<SidebarGroup>
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
"use client";
2+
3+
import { Copy, Plus, Search } from "lucide-react";
4+
import { useParams } from "next/navigation";
5+
import { useEffect, useState } from "react";
6+
import { toast } from "@/components/hooks/use-toast";
7+
import { PageHeader } from "@/components/pageHeader";
8+
import { Button } from "@/components/ui/button";
9+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
10+
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
11+
import { Input } from "@/components/ui/input";
12+
import { Skeleton } from "@/components/ui/skeleton";
13+
import { RouterOutputs } from "@/trpc";
14+
import { api } from "@/trpc/react";
15+
import { SavedReplyForm } from "./savedReplyForm";
16+
17+
type SavedReply = RouterOutputs["mailbox"]["savedReplies"]["list"][number];
18+
19+
export default function SavedRepliesPage() {
20+
const params = useParams();
21+
const mailboxSlug = params.mailbox_slug as string;
22+
23+
const [searchTerm, setSearchTerm] = useState("");
24+
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
25+
const [showCreateDialog, setShowCreateDialog] = useState(false);
26+
const [editingSavedReply, setEditingSavedReply] = useState<SavedReply | null>(null);
27+
28+
// Debounce search term to avoid losing focus on every keystroke
29+
useEffect(() => {
30+
const timer = setTimeout(() => {
31+
setDebouncedSearchTerm(searchTerm);
32+
}, 300);
33+
34+
return () => clearTimeout(timer);
35+
}, [searchTerm]);
36+
37+
const {
38+
data: savedReplies,
39+
refetch,
40+
isLoading,
41+
} = api.mailbox.savedReplies.list.useQuery({
42+
mailboxSlug,
43+
search: debouncedSearchTerm || undefined,
44+
});
45+
46+
const handleCreateSuccess = () => {
47+
setShowCreateDialog(false);
48+
refetch();
49+
toast({ title: "Saved reply created successfully", variant: "success" });
50+
};
51+
52+
const handleEditSuccess = () => {
53+
setEditingSavedReply(null);
54+
refetch();
55+
toast({ title: "Saved reply updated successfully", variant: "success" });
56+
};
57+
58+
const handleCopySavedReply = async (content: string) => {
59+
try {
60+
await navigator.clipboard.writeText(content);
61+
toast({ title: "Saved reply copied to clipboard", variant: "success" });
62+
} catch (error) {
63+
toast({ title: "Failed to copy saved reply", variant: "destructive" });
64+
}
65+
};
66+
67+
const filteredSavedReplies = savedReplies || [];
68+
const hasRepliesOrSearch = filteredSavedReplies.length > 0 || searchTerm.length > 0;
69+
70+
return (
71+
<div className="flex-1 flex flex-col">
72+
<PageHeader title="Saved replies">
73+
{hasRepliesOrSearch && (
74+
<div className="flex items-center gap-4">
75+
<div className="relative">
76+
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
77+
<Input
78+
placeholder="Search saved replies..."
79+
value={searchTerm}
80+
onChange={(e) => setSearchTerm(e.target.value)}
81+
className="w-64"
82+
/>
83+
</div>
84+
85+
<Button onClick={() => setShowCreateDialog(true)}>
86+
<Plus className="h-4 w-4 mr-2" />
87+
New saved reply
88+
</Button>
89+
</div>
90+
)}
91+
</PageHeader>
92+
93+
<div className="flex-1 space-y-6 p-6">
94+
{isLoading ? (
95+
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
96+
{[...Array(6)].map((_, i) => (
97+
<Card key={i}>
98+
<CardHeader className="pb-3">
99+
<Skeleton className="h-4 w-3/4" />
100+
<Skeleton className="h-3 w-1/2 mt-2" />
101+
</CardHeader>
102+
<CardContent className="pt-0">
103+
<div className="space-y-2">
104+
<Skeleton className="h-3 w-full" />
105+
<Skeleton className="h-3 w-5/6" />
106+
<Skeleton className="h-3 w-4/6" />
107+
</div>
108+
</CardContent>
109+
</Card>
110+
))}
111+
</div>
112+
) : (
113+
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
114+
{filteredSavedReplies.map((savedReply) => (
115+
<Card
116+
key={savedReply.slug}
117+
className="hover:shadow-md transition-shadow cursor-pointer flex flex-col"
118+
onClick={() => setEditingSavedReply(savedReply)}
119+
data-testid="saved-reply-card"
120+
>
121+
<CardHeader className="pb-3">
122+
<div className="flex items-start justify-between">
123+
<div className="space-y-1 flex-1">
124+
<CardTitle className="text-lg line-clamp-1">{savedReply.name}</CardTitle>
125+
</div>
126+
<Button
127+
variant="ghost"
128+
size="sm"
129+
onClick={(e) => {
130+
e.stopPropagation();
131+
handleCopySavedReply(savedReply.content);
132+
}}
133+
data-testid="copy-button"
134+
>
135+
<Copy className="h-4 w-4" data-testid="copy-icon" />
136+
</Button>
137+
</div>
138+
</CardHeader>
139+
<CardContent className="pt-0 flex-1 flex flex-col">
140+
<div className="text-sm text-muted-foreground line-clamp-3 mb-4 flex-1">{savedReply.content}</div>
141+
<div className="flex items-center justify-start text-xs text-muted-foreground mt-auto">
142+
<span>Used {savedReply.usageCount} times</span>
143+
</div>
144+
</CardContent>
145+
</Card>
146+
))}
147+
</div>
148+
)}
149+
150+
{!isLoading && filteredSavedReplies.length === 0 && (
151+
<div className="text-center py-12">
152+
<div className="text-muted-foreground mb-4">
153+
{searchTerm ? "No saved replies found matching your search" : "No saved replies yet"}
154+
</div>
155+
{!searchTerm && (
156+
<Button onClick={() => setShowCreateDialog(true)}>
157+
<Plus className="h-4 w-4 mr-2" />
158+
Create one
159+
</Button>
160+
)}
161+
</div>
162+
)}
163+
</div>
164+
165+
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
166+
<DialogContent className="max-w-2xl">
167+
<DialogHeader>
168+
<DialogTitle>New saved reply</DialogTitle>
169+
<DialogDescription>
170+
Create a reusable text template that can be quickly inserted into conversations.
171+
</DialogDescription>
172+
</DialogHeader>
173+
<SavedReplyForm
174+
mailboxSlug={mailboxSlug}
175+
onSuccess={handleCreateSuccess}
176+
onCancel={() => setShowCreateDialog(false)}
177+
/>
178+
</DialogContent>
179+
</Dialog>
180+
181+
{editingSavedReply && (
182+
<Dialog open={!!editingSavedReply} onOpenChange={() => setEditingSavedReply(null)}>
183+
<DialogContent className="max-w-2xl">
184+
<DialogHeader>
185+
<DialogTitle>Edit saved reply</DialogTitle>
186+
<DialogDescription>Update your saved reply template.</DialogDescription>
187+
</DialogHeader>
188+
<SavedReplyForm
189+
savedReply={editingSavedReply}
190+
mailboxSlug={mailboxSlug}
191+
onSuccess={handleEditSuccess}
192+
onCancel={() => setEditingSavedReply(null)}
193+
onDelete={() => {
194+
setEditingSavedReply(null);
195+
refetch();
196+
}}
197+
/>
198+
</DialogContent>
199+
</Dialog>
200+
)}
201+
</div>
202+
);
203+
}

0 commit comments

Comments
 (0)