diff --git a/apps/mail/app/(routes)/mail/feedback/page.tsx b/apps/mail/app/(routes)/mail/feedback/page.tsx index a43fe2136..0c93140bc 100644 --- a/apps/mail/app/(routes)/mail/feedback/page.tsx +++ b/apps/mail/app/(routes)/mail/feedback/page.tsx @@ -3,8 +3,11 @@ import { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { authProxy } from '@/lib/auth-proxy'; +import { useTRPC } from '@/providers/query-provider'; +import { useMutation } from '@tanstack/react-query'; import { useState } from 'react'; import { m } from '@/paraglide/messages'; +import { toast } from 'sonner'; import type { Route } from './+types/page'; export async function clientLoader({ request }: Route.ClientLoaderArgs) { @@ -18,12 +21,28 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { } export default function FeedbackPage() { + const trpc = useTRPC(); const [feedback, setFeedback] = useState(''); + const { mutateAsync: submitFeedback, isPending } = useMutation( + trpc.mail.submitFeedback.mutationOptions(), + ); + + const handleSend = async () => { + const trimmedFeedback = feedback.trim(); + if (!trimmedFeedback) return; + + try { + await submitFeedback({ + message: trimmedFeedback, + source: 'feedback-page', + }); - const handleSend = () => { - // TODO_doorman:need to implement - alert('Sending feedback complete'); - setFeedback(''); + toast.success('Thanks for your feedback.'); + setFeedback(''); + } catch (error) { + console.error('Failed to submit feedback:', error); + toast.error('Failed to send feedback. Please try again.'); + } }; return ( @@ -34,8 +53,8 @@ export default function FeedbackPage() { description="Share your thoughts, report issues, or suggest improvements." footer={
-
} diff --git a/apps/mail/components/mail/mail-display.tsx b/apps/mail/components/mail/mail-display.tsx index 4c24534e3..4196f0f28 100644 --- a/apps/mail/components/mail/mail-display.tsx +++ b/apps/mail/components/mail/mail-display.tsx @@ -16,15 +16,22 @@ import { Loader2, CopyIcon, CircleAlert, + MessageSquareText, + Tag, + Check, } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '../ui/dropdown-menu'; import { cn, formatDate, formatTime, shouldShowSeparateTime } from '@/lib/utils'; import { Dialog, DialogTitle, DialogHeader, DialogContent } from '../ui/dialog'; +import { Textarea } from '../ui/textarea'; import { memo, useEffect, useMemo, useState, useRef, useCallback } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; @@ -33,12 +40,13 @@ import type { Sender, ParsedMessage, Attachment } from '@/types'; import { useActiveConnection } from '@/hooks/use-connections'; import { useAttachments } from '@/hooks/use-attachments'; import { useTRPC } from '@/providers/query-provider'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Markdown } from '@react-email/components'; import { useSummary } from '@/hooks/use-summary'; import { TextShimmer } from '../ui/text-shimmer'; import { useThread } from '@/hooks/use-threads'; import { BimiAvatar } from '../ui/bimi-avatar'; +import { useLabels } from '@/hooks/use-labels'; import { PriorityScoreCircle } from './priority-score-circle'; import { RemovableTextLabels } from './removable-text-labels'; import { cleanHtml } from '@/lib/email-utils'; @@ -567,6 +575,8 @@ const MoreAboutQuery = ({ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }: Props) => { const [isCollapsed, setIsCollapsed] = useState(false); + const [priorityScoreOverride, setPriorityScoreOverride] = useState(null); + const [suggestedActionOverride, setSuggestedActionOverride] = useState(null); const { data: threadData } = useThread(emailData.threadId ?? null); const { data: messageAttachments } = useAttachments(emailData.id); // const [unsubscribed, setUnsubscribed] = useState(false); @@ -580,6 +590,8 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }: // url: string; // }>(null); const [openDetailsPopover, setOpenDetailsPopover] = useState(false); + const [openActionFeedbackDialog, setOpenActionFeedbackDialog] = useState(false); + const [actionFeedbackMessage, setActionFeedbackMessage] = useState(''); const triggerRef = useRef(null); const collapseTimeoutRef = useRef(null); @@ -587,7 +599,51 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }: const { data: activeConnection } = useActiveConnection(); const [researchSender, setResearchSender] = useState(null); const [searchQuery, setSearchQuery] = useState(null); - // const trpc = useTRPC(); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { mutateAsync: submitClassificationCorrection } = useMutation( + trpc.mail.submitClassificationCorrection.mutationOptions(), + ); + const { mutateAsync: submitActionSuggestionFeedback, isPending: isSubmittingActionFeedback } = + useMutation(trpc.mail.submitActionSuggestionFeedback.mutationOptions()); + + const { userLabels, systemLabels } = useLabels(); + const allLabels = useMemo(() => [...userLabels, ...systemLabels], [userLabels, systemLabels]); + const { mutateAsync: modifyLabels, isPending: isMovingLabel } = useMutation( + trpc.mail.modifyLabels.mutationOptions(), + ); + + const currentLabelIds = useMemo( + () => new Set((emailData.tags ?? []).map((t) => t.id ?? t.name)), + [emailData.tags], + ); + + const handleMoveToLabel = async (labelId: string, labelName: string) => { + const isAlreadyApplied = currentLabelIds.has(labelId); + try { + await modifyLabels({ + threadId: [emailData.threadId ?? emailData.id], + addLabels: isAlreadyApplied ? [] : [labelId], + removeLabels: isAlreadyApplied ? [labelId] : [], + }); + toast.success( + isAlreadyApplied ? `Removed label "${labelName}"` : `Moved to "${labelName}"`, + ); + await queryClient.invalidateQueries({ + queryKey: trpc.mail.get.queryKey({ id: emailData.threadId ?? emailData.id }), + }); + } catch (error) { + console.error('Failed to modify label:', error); + toast.error('Failed to update label.'); + } + }; + + const emailScopedSuggestedAction = useMemo(() => { + if (suggestedActionOverride) return suggestedActionOverride; + + const matchedMessage = threadData?.messages?.find((message) => message.id === emailData.id); + return matchedMessage?.suggestedAction ?? emailData.suggestedAction ?? null; + }, [suggestedActionOverride, threadData?.messages, emailData.id, emailData.suggestedAction]); const isLastEmail = useMemo( () => emailData.id === threadData?.latest?.id, @@ -1069,9 +1125,161 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }: } }; - // TODO_doorman : Implement priorityscorefeedback - const handlePriorityScoreFeedback = (_rating: 'low' | 'high') => { - alert('feedback is not implemented yet'); + const handlePriorityScoreFeedback = async (rating: 'low' | 'high') => { + try { + const correctionInput: { + threadId: string; + messageId: string; + correctedPriority: 'low' | 'high'; + currentPriorityScore?: number; + } = { + threadId: emailData.threadId ?? emailData.id, + messageId: emailData.id, + correctedPriority: rating, + }; + + if (emailData.priorityScore != null) { + correctionInput.currentPriorityScore = emailData.priorityScore; + } + + const response = await submitClassificationCorrection(correctionInput); + + if (response?.refreshed?.priorityScore != null) { + setPriorityScoreOverride(response.refreshed.priorityScore); + } + + if (response?.refreshed?.suggestedAction) { + setSuggestedActionOverride(response.refreshed.suggestedAction); + } + + const currentThreadId = emailData.threadId ?? emailData.id; + queryClient.setQueryData( + trpc.mail.get.queryKey({ id: currentThreadId }), + (existingThread: any) => { + if (!existingThread) return existingThread; + + const updatedMessages = (existingThread.messages ?? []).map((message: any) => { + if (message.id !== emailData.id) return message; + return { + ...message, + priorityScore: + response?.refreshed?.priorityScore != null + ? response.refreshed.priorityScore + : message.priorityScore, + category: response?.refreshed?.category ?? message.category, + suggestedAction: + response?.refreshed?.suggestedAction ?? message.suggestedAction, + }; + }); + + const updatedLatest = existingThread.latest?.id === emailData.id + ? { + ...existingThread.latest, + priorityScore: + response?.refreshed?.priorityScore != null + ? response.refreshed.priorityScore + : existingThread.latest.priorityScore, + category: response?.refreshed?.category ?? existingThread.latest.category, + suggestedAction: + response?.refreshed?.suggestedAction ?? existingThread.latest.suggestedAction, + } + : existingThread.latest; + + return { + ...existingThread, + messages: updatedMessages, + latest: updatedLatest, + }; + }, + ); + + await queryClient.invalidateQueries({ + queryKey: trpc.mail.get.queryKey({ id: currentThreadId }), + }); + + toast.success( + `Updated by LLM: priority score ${response?.refreshed?.priorityScore ?? 'saved'}`, + ); + } catch (error) { + console.error('Failed to submit priority score feedback:', error); + toast.error('Failed to regenerate LLM result from your feedback.'); + } + }; + + const handleActionSuggestionFeedback = async () => { + const trimmedFeedback = actionFeedbackMessage.trim(); + if (!trimmedFeedback) return; + + try { + const response = await submitActionSuggestionFeedback({ + threadId: emailData.threadId ?? emailData.id, + messageId: emailData.id, + feedbackMessage: trimmedFeedback, + currentSuggestedAction: emailScopedSuggestedAction ?? undefined, + currentPriorityScore: emailData.priorityScore ?? undefined, + }); + + if (response?.refreshed?.priorityScore != null) { + setPriorityScoreOverride(response.refreshed.priorityScore); + } + + if (response?.refreshed?.suggestedAction) { + setSuggestedActionOverride(response.refreshed.suggestedAction); + } + + const currentThreadId = emailData.threadId ?? emailData.id; + queryClient.setQueryData( + trpc.mail.get.queryKey({ id: currentThreadId }), + (existingThread: any) => { + if (!existingThread) return existingThread; + + const updatedMessages = (existingThread.messages ?? []).map((message: any) => { + if (message.id !== emailData.id) return message; + return { + ...message, + priorityScore: + response?.refreshed?.priorityScore != null + ? response.refreshed.priorityScore + : message.priorityScore, + category: response?.refreshed?.category ?? message.category, + suggestedAction: + response?.refreshed?.suggestedAction ?? message.suggestedAction, + }; + }); + + const updatedLatest = + existingThread.latest?.id === emailData.id + ? { + ...existingThread.latest, + priorityScore: + response?.refreshed?.priorityScore != null + ? response.refreshed.priorityScore + : existingThread.latest.priorityScore, + category: response?.refreshed?.category ?? existingThread.latest.category, + suggestedAction: + response?.refreshed?.suggestedAction ?? existingThread.latest.suggestedAction, + } + : existingThread.latest; + + return { + ...existingThread, + messages: updatedMessages, + latest: updatedLatest, + }; + }, + ); + + await queryClient.invalidateQueries({ + queryKey: trpc.mail.get.queryKey({ id: currentThreadId }), + }); + + setOpenActionFeedbackDialog(false); + setActionFeedbackMessage(''); + toast.success('Action feedback applied. LLM analysis refreshed.'); + } catch (error) { + console.error('Failed to submit action suggestion feedback:', error); + toast.error('Failed to refresh analysis from your action feedback.'); + } }; const renderPerson = useCallback( @@ -1161,6 +1369,45 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }: person={researchSender} /> )} + + e.stopPropagation()}> + + Improve Action Suggestion + +
+

+ Provide feedback for this email and we will update the prompt, resend to OpenAI, + and refresh the analysis result. +

+