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 onlg 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 (theh-[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 fromapi.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-smssendClientEmail->POST /api/ghl/send-emailsendInternalNote->POST /api/ghl/conversations/internal-comment
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:
- Handling
InboundMessage/OutboundMessageinapp/api/lb/webhook/route.ts. - A Convex table mirroring messages per contact, with a backfill for threads that predate the webhook.
- 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 alastMessageId 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).
updateGHLAppointmentStatusis already implemented atlib/ghl-sdk.tsand 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.
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 whenisViewAsReadOnly 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.
