> ## Documentation Index
> Fetch the complete documentation index at: https://docs.myfundingmachine.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Contact context panel

# 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.

| Tab      | Component                          | Data source                                               |
| -------- | ---------------------------------- | --------------------------------------------------------- |
| Messages | `CommsTab.tsx`                     | `GET /api/ghl/conversations`, `GET /api/ghl/appointments` |
| Docs     | `ContactDocumentsSection` (reused) | Convex `documents` + agreements                           |
| Activity | `UnifiedTimeline` (reused)         | Convex `unifiedActivity`                                  |
| Notes    | `ContactNotes` (reused)            | `GET/POST/PUT/DELETE /api/ghl/notes`                      |

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`.
