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 { 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}
/>
</div>
{/* Right panel: Contact sidebar — desktop only. */}
<div className="hidden lg:block">
<ContactSidebar contact={activeContact} />
</div>
{/* 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">
<ContactSidebar contact={activeContact} />
</div>
)}
</div>
</div>
);

View File

@ -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({
</div>
<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
conversation list (the parent bumps its resyncToken). Useful
when realtime missed an event or the agent just wants to be