feat(inbox): collapsible contact sidebar on desktop

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 <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-06-19 14:06:02 +03:00
parent c2fee3f2c5
commit 9b32954d1a
No known key found for this signature in database
2 changed files with 85 additions and 4 deletions

View File

@ -12,6 +12,10 @@ import { toast } from "sonner";
import { WifiOff } from "lucide-react"; import { WifiOff } from "lucide-react";
import { cn } from "@/lib/utils"; 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() { export default function InboxPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -39,6 +43,36 @@ export default function InboxPage() {
*/ */
const [resyncToken, setResyncToken] = useState(0); 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 // Fire the deep-link auto-select exactly once per URL — subsequent
// list refreshes (realtime, manual refetch) must not snap the user // list refreshes (realtime, manual refetch) must not snap the user
// back to the deep-linked conversation if they've already clicked // back to the deep-linked conversation if they've already clicked
@ -570,13 +604,20 @@ export default function InboxPage() {
onBack={handleCloseConversation} onBack={handleCloseConversation}
resyncToken={resyncToken} resyncToken={resyncToken}
onRefresh={handleManualRefresh} onRefresh={handleManualRefresh}
contactPanelOpen={contactPanelOpen}
onToggleContactPanel={handleToggleContactPanel}
/> />
</div> </div>
{/* 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 && (
<div className="hidden lg:block"> <div className="hidden lg:block">
<ContactSidebar contact={activeContact} /> <ContactSidebar contact={activeContact} />
</div> </div>
)}
</div> </div>
</div> </div>
); );

View File

@ -21,6 +21,8 @@ import {
Clock, Clock,
ArrowLeft, ArrowLeft,
RefreshCw, RefreshCw,
PanelRightOpen,
PanelRightClose,
} from "lucide-react"; } from "lucide-react";
import { format, isToday, isYesterday, differenceInHours } from "date-fns"; import { format, isToday, isYesterday, differenceInHours } from "date-fns";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@ -92,6 +94,15 @@ interface MessageThreadProps {
* working; the button is only rendered when this is provided. * working; the button is only rendered when this is provided.
*/ */
onRefresh?: () => void; 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 { function formatDateSeparator(dateStr: string): string {
@ -148,6 +159,8 @@ export function MessageThread({
onBack, onBack,
resyncToken = 0, resyncToken = 0,
onRefresh, onRefresh,
contactPanelOpen,
onToggleContactPanel,
}: MessageThreadProps) { }: MessageThreadProps) {
const { user } = useAuth(); const { user } = useAuth();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -839,6 +852,33 @@ export function MessageThread({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* 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 && (
<button
type="button"
onClick={onToggleContactPanel}
aria-label={
contactPanelOpen ? "Hide contact panel" : "Show contact panel"
}
aria-pressed={contactPanelOpen}
title={contactPanelOpen ? "Hide contact" : "Show contact"}
className={cn(
"hidden h-7 w-7 items-center justify-center rounded-md transition-colors hover:bg-muted hover:text-foreground lg:inline-flex",
contactPanelOpen ? "text-primary" : "text-muted-foreground",
)}
>
{contactPanelOpen ? (
<PanelRightClose className="h-4 w-4" />
) : (
<PanelRightOpen className="h-4 w-4" />
)}
</button>
)}
{/* Manual refresh — forces a refetch of the messages + the {/* Manual refresh — forces a refetch of the messages + the
conversation list (the parent bumps its resyncToken). Useful conversation list (the parent bumps its resyncToken). Useful
when realtime missed an event or the agent just wants to be when realtime missed an event or the agent just wants to be