From 9b32954d1a086749509b1d0ef7b0127b6ac53110 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 19 Jun 2026 14:06:02 +0300 Subject: [PATCH] feat(inbox): collapsible contact sidebar on desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a desktop-only toggle in the message-thread header to show/hide the contact sidebar (tags, deals, notes). On smaller laptops the always-on panel crowds the message thread; agents who only want to read and reply can now reclaim that horizontal space. The page owns the open/closed state and conditionally renders the sidebar; the thread renders the toggle button and reflects the state. The choice is persisted to localStorage (device-scoped, like the theme prefs) so it survives reloads. To avoid a hydration mismatch we default to open on the server and reconcile from storage in an effect after mount. Mobile is unchanged — the sidebar is never a permanent panel there and the toggle is hidden below lg. Closes #258 Co-Authored-By: Claude Opus 4.8 --- src/app/(dashboard)/inbox/page.tsx | 49 +++++++++++++++++++++++-- src/components/inbox/message-thread.tsx | 40 ++++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/app/(dashboard)/inbox/page.tsx b/src/app/(dashboard)/inbox/page.tsx index 16ee965..7ec3895 100644 --- a/src/app/(dashboard)/inbox/page.tsx +++ b/src/app/(dashboard)/inbox/page.tsx @@ -12,6 +12,10 @@ import { toast } from "sonner"; import { WifiOff } from "lucide-react"; import { cn } from "@/lib/utils"; +// Remembers the agent's show/hide choice for the desktop contact panel +// across reloads and sessions (device-scoped, like the theme prefs). +const CONTACT_PANEL_STORAGE_KEY = "wacrm:inbox:contact-panel-open"; + export default function InboxPage() { const router = useRouter(); const searchParams = useSearchParams(); @@ -39,6 +43,36 @@ export default function InboxPage() { */ const [resyncToken, setResyncToken] = useState(0); + /** + * Whether the desktop contact sidebar (tags / deals / notes) is shown. + * Defaults to `true` (the historical behaviour) and is restored from + * localStorage after mount. We deliberately do NOT read localStorage in + * the initializer: the server renders with `true`, so reading a stored + * `false` synchronously would produce a hydration mismatch. The effect + * below reconciles to the stored value right after mount instead. + */ + const [contactPanelOpen, setContactPanelOpen] = useState(true); + useEffect(() => { + try { + const stored = localStorage.getItem(CONTACT_PANEL_STORAGE_KEY); + if (stored !== null) setContactPanelOpen(stored === "true"); + } catch { + // localStorage can throw in private-browsing / sandboxed contexts. + } + }, []); + + const handleToggleContactPanel = useCallback(() => { + setContactPanelOpen((prev) => { + const next = !prev; + try { + localStorage.setItem(CONTACT_PANEL_STORAGE_KEY, String(next)); + } catch { + // Persistence is best-effort; ignore storage failures. + } + return next; + }); + }, []); + // Fire the deep-link auto-select exactly once per URL — subsequent // list refreshes (realtime, manual refetch) must not snap the user // back to the deep-linked conversation if they've already clicked @@ -570,13 +604,20 @@ export default function InboxPage() { onBack={handleCloseConversation} resyncToken={resyncToken} onRefresh={handleManualRefresh} + contactPanelOpen={contactPanelOpen} + onToggleContactPanel={handleToggleContactPanel} /> - {/* Right panel: Contact sidebar — desktop only. */} -
- -
+ {/* Right panel: Contact sidebar — desktop only, and only when the + agent hasn't collapsed it via the thread-header toggle (#258). + On mobile it's always hidden (the `lg:block` below), so the + toggle — which is itself desktop-only — never affects it. */} + {contactPanelOpen && ( +
+ +
+ )} ); diff --git a/src/components/inbox/message-thread.tsx b/src/components/inbox/message-thread.tsx index 46053f5..770c2d4 100644 --- a/src/components/inbox/message-thread.tsx +++ b/src/components/inbox/message-thread.tsx @@ -21,6 +21,8 @@ import { Clock, ArrowLeft, RefreshCw, + PanelRightOpen, + PanelRightClose, } from "lucide-react"; import { format, isToday, isYesterday, differenceInHours } from "date-fns"; import { Badge } from "@/components/ui/badge"; @@ -92,6 +94,15 @@ interface MessageThreadProps { * working; the button is only rendered when this is provided. */ onRefresh?: () => void; + /** + * Desktop-only contact-panel toggle. The page owns the open/closed + * state (it's the one that renders the sidebar), so the thread just + * reflects it and asks the page to flip it. Both optional so existing + * callers keep working; the toggle button only renders when + * `onToggleContactPanel` is wired up. + */ + contactPanelOpen?: boolean; + onToggleContactPanel?: () => void; } function formatDateSeparator(dateStr: string): string { @@ -148,6 +159,8 @@ export function MessageThread({ onBack, resyncToken = 0, onRefresh, + contactPanelOpen, + onToggleContactPanel, }: MessageThreadProps) { const { user } = useAuth(); const [loading, setLoading] = useState(false); @@ -839,6 +852,33 @@ export function MessageThread({
+ {/* Contact-panel toggle — desktop only. The contact sidebar + eats a chunk of horizontal width that crowds the thread on + smaller laptops; this lets agents reclaim it when they just + want to read and reply. Hidden on mobile, where the sidebar + never renders as a permanent panel anyway. Issue #258. */} + {onToggleContactPanel && ( + + )} + {/* Manual refresh — forces a refetch of the messages + the conversation list (the parent bumps its resyncToken). Useful when realtime missed an event or the agent just wants to be