From 5d0944e08792eb914d9467de4e54905de131a8a6 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Mon, 18 May 2026 15:14:42 +0300 Subject: [PATCH 1/2] feat(inbox): wire conversation assignment dropdown Replace the no-op Assign button in the thread header with a dropdown that reads/writes conversations.assigned_agent_id. Lists profiles visible under RLS (single-tenant today, ready for shared workspaces) and offers Unassign when the conversation is already claimed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(dashboard)/inbox/page.tsx | 21 ++++ src/components/inbox/message-thread.tsx | 131 ++++++++++++++++++++++-- 2 files changed, 141 insertions(+), 11 deletions(-) diff --git a/src/app/(dashboard)/inbox/page.tsx b/src/app/(dashboard)/inbox/page.tsx index ffbf775..0ea5c0a 100644 --- a/src/app/(dashboard)/inbox/page.tsx +++ b/src/app/(dashboard)/inbox/page.tsx @@ -256,6 +256,26 @@ export default function InboxPage() { [activeConversation] ); + const handleAssignChange = useCallback( + (conversationId: string, assignedAgentId: string | null) => { + setConversations((prev) => + prev.map((c) => + c.id === conversationId + ? { ...c, assigned_agent_id: assignedAgentId ?? undefined } + : c + ) + ); + if (activeConversation?.id === conversationId) { + setActiveConversation((prev) => + prev + ? { ...prev, assigned_agent_id: assignedAgentId ?? undefined } + : prev + ); + } + }, + [activeConversation] + ); + // On mobile ( diff --git a/src/components/inbox/message-thread.tsx b/src/components/inbox/message-thread.tsx index c3b8671..d8af203 100644 --- a/src/components/inbox/message-thread.tsx +++ b/src/components/inbox/message-thread.tsx @@ -2,22 +2,30 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { createClient } from "@/lib/supabase/client"; +import { useAuth } from "@/hooks/use-auth"; import { cn } from "@/lib/utils"; -import type { Conversation, Message, Contact, ConversationStatus } from "@/types"; +import type { + Conversation, + Message, + Contact, + ConversationStatus, + Profile, +} from "@/types"; import { MessageSquare, ChevronDown, UserPlus, + Check, Clock, ArrowLeft, } from "lucide-react"; import { format, isToday, isYesterday, differenceInHours } from "date-fns"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -33,6 +41,10 @@ interface MessageThreadProps { onNewMessage: (message: Message) => void; onUpdateMessage: (id: string, updates: Partial) => void; onStatusChange: (conversationId: string, status: ConversationStatus) => void; + onAssignChange: ( + conversationId: string, + assignedAgentId: string | null, + ) => void; /** * On mobile, the thread is shown full-screen with the conversation list * hidden. This callback lets the page deselect the active conversation @@ -80,11 +92,37 @@ export function MessageThread({ onNewMessage, onUpdateMessage, onStatusChange, + onAssignChange, onBack, }: MessageThreadProps) { + const { user } = useAuth(); const [loading, setLoading] = useState(false); const scrollRef = useRef(null); const [templateModalOpen, setTemplateModalOpen] = useState(false); + const [profiles, setProfiles] = useState([]); + + // Profiles are bounded by RLS to rows the current user is allowed to + // see — today that's just the current user, but the dropdown keeps the + // shape ready for shared-team workspaces without a refactor. + useEffect(() => { + let cancelled = false; + const supabase = createClient(); + supabase + .from("profiles") + .select("*") + .order("full_name") + .then(({ data, error }) => { + if (cancelled) return; + if (error) { + console.error("Failed to fetch profiles:", error); + return; + } + setProfiles((data as Profile[]) ?? []); + }); + return () => { + cancelled = true; + }; + }, []); // 24-hour session timer const sessionInfo = useMemo(() => { @@ -266,6 +304,27 @@ export function MessageThread({ // Template modal implementation would go here }, []); + const handleAssignChange = useCallback( + async (agentId: string | null) => { + if (!conversation) return; + + const supabase = createClient(); + const { error } = await supabase + .from("conversations") + .update({ assigned_agent_id: agentId }) + .eq("id", conversation.id); + + if (error) { + console.error("Failed to update assignment:", error); + toast.error("Failed to update assignment"); + return; + } + + onAssignChange(conversation.id, agentId); + }, + [conversation, onAssignChange], + ); + // Empty state if (!conversation || !contact) { return ( @@ -288,6 +347,11 @@ export function MessageThread({ const currentStatus = STATUS_OPTIONS.find( (s) => s.value === conversation.status ); + const assignedAgentId = conversation.assigned_agent_id ?? null; + const currentAssignee = profiles.find((p) => p.user_id === assignedAgentId); + const assignLabel = assignedAgentId + ? (currentAssignee?.full_name ?? "Assigned") + : "Assign"; return (
@@ -353,15 +417,60 @@ export function MessageThread({ - {/* Assign button */} - + {/* Assign dropdown */} + + + + {assignLabel} + + + + {profiles.length === 0 ? ( + + No teammates available + + ) : ( + profiles.map((p) => { + const isSelected = p.user_id === assignedAgentId; + return ( + handleAssignChange(p.user_id)} + className={cn( + "text-sm", + isSelected ? "text-violet-400" : "text-slate-300" + )} + > + + {p.full_name} + {p.user_id === user?.id ? " (me)" : ""} + + {isSelected && } + + ); + }) + )} + {assignedAgentId && ( + <> + + handleAssignChange(null)} + className="text-sm text-slate-400" + > + Unassign + + + )} + +
From 8d83220df47e105649a5f2084d6000f324c58b9c Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Mon, 18 May 2026 15:26:47 +0300 Subject: [PATCH 2/2] chore(broadcasts): drop the inert Schedule for Later tile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pro-badged Schedule for Later tile in step 4 was a non-interactive div — no handler, no scheduling backend, and no Pro tier in the product. Send Immediately was the only path that did anything, so the picker itself was decorative. Remove the section (and the now-unused timing state / Clock / Lock imports). The wizard still completes Send Immediately end-to-end via the existing Send Broadcast button below. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../broadcasts/step4-schedule-send.tsx | 38 +------------------ 1 file changed, 1 insertion(+), 37 deletions(-) diff --git a/src/components/broadcasts/step4-schedule-send.tsx b/src/components/broadcasts/step4-schedule-send.tsx index b07893b..98c23c3 100644 --- a/src/components/broadcasts/step4-schedule-send.tsx +++ b/src/components/broadcasts/step4-schedule-send.tsx @@ -14,7 +14,7 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; -import { ArrowLeft, Send, Clock, Loader2, Users, Lock, Save } from 'lucide-react'; +import { ArrowLeft, Send, Loader2, Users, Save } from 'lucide-react'; interface AudienceConfig { type: string; @@ -45,7 +45,6 @@ export function Step4ScheduleSend({ isProcessing, progress, }: Step4Props) { - const [timing, setTiming] = useState<'now' | 'later'>('now'); const [showConfirm, setShowConfirm] = useState(false); const [estimatedReach, setEstimatedReach] = useState(0); const [loadingReach, setLoadingReach] = useState(true); @@ -143,41 +142,6 @@ export function Step4ScheduleSend({ - {/* Delivery Timing */} -
-

Delivery Timing

-
- - -
- -
-
-

Schedule for Later

- - - Pro - -
-

Pick a date and time

-
-
-
-
- {/* Processing overlay */} {isProcessing && (