ConversationTaskRow.tsx7.3 KBView on GitHub /**
* ConversationTaskRow — renders a user task in the same visual style as the
* Thread component row (avatar slot → checkbox, subject → description, date →
* due date, hover actions for complete / open thread).
*/
import type { ConversationUserTask } from '@/modules/crm/types';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useTRPC } from '@/providers/query-provider';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCedarStore } from '@/modules/store';
import { Checkbox } from '@/components/ui/checkbox';
import { format } from 'date-fns';
import { TaskLine } from '@/modules/userTasks/components/TaskLine';
import { cn } from '@/lib/utils';
import { Check } from 'lucide-react';
import { toast } from 'sonner';
import { memo, useCallback } from 'react';
interface ConversationTaskRowProps {
task: ConversationUserTask;
className?: string;
/** Removes outer mx-2, reduces inner gap/padding, and shrinks checkbox hover area */
compact?: boolean;
}
export const ConversationTaskRow = memo(function ConversationTaskRow({
task,
className,
compact = false,
}: ConversationTaskRowProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const selectThreadId = useCedarStore((state) => state.selectThreadId);
const setIsThreadOpen = useCedarStore((state) => state.setIsThreadOpen);
const isDone = task.status === 'done';
const completeMutation = useMutation({
...trpc.userTasks.completeTask.mutationOptions(),
/**
* All four reads carry `userTasks`, and leaving any of them stale is how a ticked row comes
* straight back. This row used to refresh only `conversationInbox.list` — so the Strategic
* Overview (`crm.getConversation`), the task lists (`userTasks.listUserTasks`) and the CRM
* table (`crm.listConversations`) kept serving the task as `todo` after it was completed
* here. The main checkbox path has always invalidated all three; this one is the same
* capability and needs the same convergence (design: zach-sept10-bugs.md §1).
*/
onSuccess: () => {
void Promise.all([
queryClient.invalidateQueries({ queryKey: [['conversationInbox', 'list']] }),
queryClient.invalidateQueries({ queryKey=[redacted] }),
queryClient.invalidateQueries({
queryKey=[redacted] id: task.conversationId }),
}),
queryClient.invalidateQueries({ queryKey: [['crm', 'listConversations']] }),
]);
},
/**
* A failure here means the row on screen disagrees with the server, so the useful response is
* to go and find out, not just to complain. Without the refetch the stale row stays put and
* the next tick fails identically — the loop from Zach's report, in miniature.
*/
onError: () => {
toast.error('Failed to complete task');
void queryClient.invalidateQueries({
queryKey=[redacted] id: task.conversationId }),
});
},
});
const handleComplete = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (isDone) return;
// Ticking cleans up the task's Gmail draft, matching delete. Completion is one-way on this
// row (`isDone` short-circuits), so there is no undo window to hold the cleanup behind.
completeMutation.mutate({ taskId: task.id, cleanupDraft: true });
},
[isDone, completeMutation, task.id],
);
const handleRowClick = useCallback(() => {
const actionData = task.taskActionData;
if (actionData?.channel === 'email' && actionData.threadId) {
selectThreadId(actionData.threadId);
setIsThreadOpen(true);
}
}, [task.taskActionData, selectThreadId, setIsThreadOpen]);
const hasThread =
task.taskActionData?.channel === 'email' && !!task.taskActionData.threadId;
return (
<>
{/* Divider */}
<div className="border-border/75 mx-2 border-t" />
<div
className={cn(
'group relative flex cursor-pointer flex-col items-start rounded-lg py-1.5 text-left text-sm md:py-2.5',
compact ? 'mx-0' : 'mx-2',
'hover:bg-action/12',
isDone && 'opacity-50',
className,
)}
onClick={hasThread ? handleRowClick : undefined}
>
<div className={cn('relative flex w-full items-center', compact ? 'gap-2 px-2' : 'gap-4 px-3')}>
{/* Checkbox slot (mirrors avatar slot in Thread) */}
<div
className="relative flex h-6 w-6 shrink-0 items-center justify-center"
onClick={handleComplete}
>
<div className={cn('hover:bg-primary/20 absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors', compact ? 'h-6 w-6' : 'h-8 w-8')} />
<Tooltip>
<TooltipTrigger asChild>
<Checkbox
checked={isDone}
className="relative h-4 w-4"
onClick={(e) => e.stopPropagation()}
onCheckedChange={() => handleComplete({ stopPropagation: () => {} } as React.MouseEvent)}
/>
</TooltipTrigger>
<TooltipContent side="top" className="bg-raised mb-1 text-xs">
{isDone ? 'Completed' : 'Mark complete'}
</TooltipContent>
</Tooltip>
</div>
<div className="flex min-w-0 flex-1 items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
{/* Task type badge (mirrors the sender name column) */}
<span
className={cn(
'text-foreground flex w-36 shrink-0 items-center gap-1 text-sm xl:w-44 2xl:w-52',
isDone ? 'font-normal' : 'font-medium',
)}
>
<span className="min-w-0 truncate text-xs font-medium uppercase tracking-wide text-muted-foreground">
{task.taskType ?? 'Task'}
</span>
</span>
{/* Unread dot placeholder — always invisible for tasks, keeps subject aligned */}
<span className="mr-0.5 flex size-2 shrink-0 items-center rounded-full invisible" />
{/* Description (mirrors subject) */}
{task.description && (
<TaskLine description={task.description} done={isDone} truncate />
)}
{/* Completed indicator */}
{isDone && (
<span className="flex shrink-0 items-center gap-0.5 text-xs text-green-600 dark:text-green-400">
<Check className="h-3 w-3" />
Done
</span>
)}
</div>
{/* Due date (mirrors thread date) */}
{task.dueDate && (
<p
className={cn(
'w-16 shrink-0 text-right text-xs font-normal opacity-85',
'text-muted-foreground dark:text-[#8C8C8C]',
new Date(task.dueDate) < new Date() && !isDone
? 'text-red-500 dark:text-red-400 opacity-100'
: '',
)}
>
{format(new Date(task.dueDate as string | Date), 'MMM d')}
</p>
)}
</div>
</div>
</div>
</>
);
});