Skip to main content

Comms rail (contact context panel)

Docked right-hand rail on the contact page (/contact, and the admin mirror at /admin/internal/contact) that puts messaging, documents, activity, and notes in one place without leaving the page. components/contact/context-panel/ContactContextRail.tsx is rendered as part of the page layout in components/contact/ContactPageContent.tsx — not as an overlay. It is deliberately not in ContactPageOverlays.tsx.

Layout

The rail is always present. Collapsed it is a 44px strip holding an expand arrow, one icon per tab, and a vertical “Comms” wordmark; clicking any icon opens straight to that tab. Expanded it takes real width on lg and up, so the main column shrinks and the credit and decision content stays visible next to the conversation. Below lg it switches to absolute and overlays instead, because pushing would crush the dense contact layout. Open/collapsed state persists in localStorage under fm.contactContextRail.open, read after mount so server and client markup agree. The rail sits at z-[60], above StickyDecisionBar (z-50), which is fixed inset-x-0 and would otherwise cover the composer.

The rail’s height comes from the page shell

The composer sits at the bottom of the rail, and the rail stretches to the height of the contact shell (the h-[calc(100dvh-3rem)] overflow-hidden flex row in ContactPageContent.tsx). That shell must therefore keep a definite height: it must not carry flex-1, because the root layout’s column has no definite height of its own, so a 0% flex basis wins over the height declaration and sizes the shell to its content. When that happened the shell grew past 3,400px on a real contact, the whole document scrolled instead of the tab content, and the composer sat at the bottom of that scroll instead of on screen. tests/contactContextPanelReview.test.ts guards the class list. The admin mirror is still ~40–70px taller than the space AdminShell leaves it (56px header plus main padding on top of a 100dvh - 3rem shell), so the composer’s bottom edge can fall just under the fold there. That offset predates the rail and belongs to the admin chrome, not to this component. Only the Messages tab is new. The other three compose components the Docs & Notes main tab already renders, which stays in place — consolidating the two is a separate decision once operators have used both. The Messages tab stays mounted and is hidden with CSS rather than unmounted, so an in-progress draft survives a trip to another tab. Its poll is gated on the rail being open and Messages being the active tab, so a hidden tab costs nothing. Tabs implement roving focus with arrow, Home, and End keys.

Tab badges

Only Docs carries a count. It comes from api.contactPageData.getContactDocumentsBundle, which the contact page already subscribes to, so Convex dedupes it and the badge costs no extra query. A Notes badge was deliberately left out: the count is only knowable by calling GHL, and doing that at panel level would duplicate the fetch ContactNotes already performs when its tab opens.

Sending

lib/ghl/sendClientMessage.ts holds the three send paths, shared with the existing components/communication/MessageClient.tsx dialog so error handling does not drift between the two surfaces:
  • sendClientSms -> POST /api/ghl/send-sms
  • sendClientEmail -> POST /api/ghl/send-email
  • sendInternalNote -> POST /api/ghl/conversations/internal-comment
Internal notes require a GHL user id for attribution; the composer disables that channel when currentUserId is absent. Email templates reuse the existing underwriting-keyed picker (api.automations.getSettingsForLocation + api.automationsNode.renderTemplateForContact). There is no separate template taxonomy for the panel.

Freshness: polling, not webhooks

lib/hooks/useContactConversations.ts polls GET /api/ghl/conversations every 20 seconds. Polling runs only while the panel is open and document.visibilityState === 'visible', so a backgrounded tab does not keep consuming GHL API quota. It refetches immediately on tab refocus and after every successful send. The poll interval is the floor on inbound reply latency. This app has no InboundMessage, OutboundMessage, or ConversationUnreadUpdate webhook handler — unhandled GHL event types fall through to the ignored: true branch in app/api/lb/webhook/route.ts. Nothing pushes a client’s reply to us. Making the thread genuinely reactive would require, roughly:
  1. Handling InboundMessage / OutboundMessage in app/api/lb/webhook/route.ts.
  2. A Convex table mirroring messages per contact, with a backfill for threads that predate the webhook.
  3. Swapping the poll hook for a Convex subscription.
addGHLInboundMessage already exists at lib/ghl-sdk.ts but is currently dead code — nothing in the repo calls it.

Polling vs “Load older”

GHL paginates messages per conversation with a lastMessageId cursor, and a poll always requests the newest page, so the two paths fight over the same cursor state. reconcilePageInfo in the hook settles it: the poll response is authoritative for which conversations exist and for how far back each conversation that has not been paged reaches, while a cursor loadOlder already advanced (and that page’s own nextPage) survives later polls. Both halves matter. Keeping a stale nextPage: false hides older history for the rest of the session once a single-page thread outgrows one page, and letting a poll adopt the newest page’s cursor every 20 seconds would pin the operator to the top of the thread. tests/useContactConversations.test.ts pins both.

Appointments

GET /api/ghl/appointments wraps getGHLContactAppointments and returns the next upcoming appointment plus up to five recent past ones. NextAppointmentCard.tsx renders the upcoming one read-only, with its GHL status. “Next” means the soonest future event that is still live. Statuses are compared through the shared normalizeAppointmentStatus, and cancelled, invalid, showed, and noshow are all terminal: a future-dated event carrying one of them has already been dispositioned, and showing it as the next booking would tell the operator to expect a meeting that is over. An unrecognized status stays eligible so a real booking is never hidden. Terminal events still appear in past. The route exists separately from /api/ghl/crm-activity, which also returns appointments, because that endpoint fans out to five upstream sources including a full conversation message fetch — far too heavy for the panel to call.

Deferred: appointment booking and rescheduling

Full booking from the panel is wanted but out of scope for the initial build. What it needs, in rough order of cost:
  • Confirm / cancel / no-show (cheapest). updateGHLAppointmentStatus is already implemented at lib/ghl-sdk.ts and has no callers. It needs an API route and a control on the card. No new GHL surface area.
  • Rescheduling. Needs a GHL edit-appointment wrapper plus free-slot lookup so the operator picks a valid time. Neither exists today.
  • Net-new booking. Additionally needs calendar selection for the location and assigned-user resolution.
Also relevant: AppointmentCreate / AppointmentUpdate webhooks are received at app/api/lb/webhook/route.ts but deliberately delegated (lib/webhooks/appointment-handler.ts logs and returns delegated: true), so appointment state changed elsewhere will not sync back into this app until that delegation is revisited.

View-as / read-only

The rail is not hidden or collapsed when isViewAsReadOnly is true. Its read tabs stay useful while impersonating; the composer withholds itself instead, and ContactDocumentsSection and ContactNotes receive the read-only flag. Because the rail owns its own open state rather than borrowing the page’s dialog state, it is unaffected by the view-as reset effect in components/contact/hooks/useContactFormState.ts.