0.6.0 — SMS becomes a native app on live PitchPrfct messaging, dial email tab searches all mail
SMS graduates from a hidden three-panel canvas widget to a first-class native app — full-height inbox rail with search + All/Unread/Starred tabs, thread pane, service picker, and real-time messages via a HMAC-verified PitchPrfct webhook → Supabase Realtime pipeline. Dial's email tab now searches all mail (not just inbox), incoming email no longer auto-marks itself read on arrival, and dialer SMS sends reuse the conversation's existing number instead of thrashing the phone-numbers API.
What's new
SMS: native app + live PitchPrfct messaging
SMS graduates from the hidden three-panel canvas widget to a
first-class native app (kind: 'native') that mounts the way
Dial, Email, and Calendar do: opening SMS from the sidebar mounts a
full-bleed SmsWorkspace — no canvas placement step, no widget to
drag.
- Full-height inbox rail (
SmsInboxRail) layered over the header like the dialer's contact-card sidebar and drag-resizable. Debounced server-side search across name / phone / snippet, plus All / Unread / Starred tabs (server-sidesearch/isRead/starredfilters with an optimistic client mirror). Unread dots, hover star toggles, and layout-matchedSkeletonRegionloading states. - Paginated conversations. 25 conversations per page via
useInfiniteQuery, infinite-scroll with a Load More fallback — replacing the single 50-row pull that took 20 s+ on a heavy account. - Service picker — a shared
smsProviders.jsxcatalog (PitchPrfct active, Project88 coming soon) drives both this app and the dialer's SMS tab. TextDrip is removed. - The
smsInboxWidgetcanvas widget shares the same internals and remains addable to any board viasmsWidgets— so a canvas you already dropped the inbox on keeps rendering.
See SMS app and Dial → SMS tab.
Live messages: PitchPrfct → webhook → Supabase Realtime
The SMS surface no longer waits on a poll to see a new inbound message. Deliveries fan out end-to-end via a webhook pipeline that mirrors the Gmail push architecture:
- PitchPrfct webhook →
pitchprfct-webhookedge function →sms_events(migration158) → Supabase Realtime → cache invalidation inuseSmsRealtime. - HMAC-SHA256 verified on
{timestamp}.{body}with a replay window, plus a per-integration URL token. Events are idempotent on delivery id; 30-day retention. - Auto-registration. The webhook registers itself the first
time an operator opens the SMS surface, via a JWT-authed
register action that resolves the user's vault API key and
idempotently creates or adopts the PitchPrfct webhook (the
message.*events). Verified in prod: real signed deliveries land insms_eventsand drive the UI within ~1 s. - Polling demoted to fallback — thread 20 s, inbox 60 s. The dial widget's SMS tab mounts the realtime hook too, so it benefits from push updates on the same wire.
Performance: measured against a heavy account
- Thread open skips the phone → contact lookup when the row
already carries
contactUuid(1 upstream call instead of 2), plus a 150 ms hover-intent prefetch so clicks paint from cache most of the time. - Paginated inbox — see above.
- Transient upstream 5xx retry. PitchPrfct's
/conversationsintermittently hangs and recovers; one retry on 5xx / timeout (never on 401 / 429) removes the "loading forever" state.pitchprfct-apinow logs the upstream 5xx path so the pattern is visible in edge logs.
Client / API corrections
While wiring live messages we also fixed three off-spec calls in the typed PitchPrfct client:
conversations.updatenow exists (PATCH{isRead}).bulkUpdatemoved to POST per the upstream OpenAPI spec.- Star is contact-level (
PATCH /contacts/{uuid} {starred}) — it's a per-contact flag, not a per-conversation one.
Fixes
Dial → Email tab: search all mail, not just the inbox
The dial widget's Email tab was showing "No emails" for
leads that had been emailed but hadn't replied. Root cause: the
lead-participant search (from:X OR to:X) called the list API
without a folder, and email-api's handleList defaulted to
the inbox folder — labelIds=INBOX on Gmail, mailFolders/inbox
on Graph. Every email sent to a lead lives in Sent, so leads who
hadn't replied (the common dial case) showed an empty tab.
- New folder mode
"all". Gmail drops the label restriction (plainq=search covers all mail; spam/trash excluded by default), Graph queries/me/messagesacross folders.useDialEmailForLeadpassesfolder: 'all'. - Graph
$orderby+$searchconflict fixed. Graph rejects the combination with a 400 — any Outlook account would have errored on this tab. With a query present,$orderbyis now omitted (the client already re-sorts by timestamp). - List-URL construction extracted to pure helpers
(
buildGmailListUrl/buildMsListUrl) inemail-api/lib.ts, with Deno tests pinning the folder scoping and$search/$orderbyrules.
email-api deployed to prod as v18; deployed source is
byte-identical to this branch.
See Dial → Email tab.
Inbox: incoming email no longer auto-marks itself read
Every arriving message was silently consumed as if the operator
had opened it. Root cause: fetchEmails() auto-selected the newest
email on every fetch, and the realtime email_events subscription
refetches on every arrival. Auto-selection triggered the mark-read
effects, so mail was marked read the moment it landed — app-wide,
since EmailProvider mounts above every surface.
The email store now tracks selection source:
- Only user clicks (
setActiveEmailId, which every widget row uses) mark the message + its thread read. - Code-driven auto-selection never marks read.
- Refetches preserve the current selection instead of hijacking it to the newest message, so the reader no longer jumps on arrivals.
- Clicking the already-auto-selected row still consumes unread state — the selection nonce re-fires the effects.
See Inbox.
Dial SMS: send from the number the conversation lives on
Dial SMS sends had been failing since Aug 9 when PitchPrfct
silently revoked GET /api/v1/phone-numbers access for existing
API keys (403 Forbidden; all other endpoints unaffected). Every
send resolved fromNumber through that endpoint first, so the
POST /api/v1/messages never fired — the composer just showed
"Tap to retry".
fromNumber resolution is now, in order:
localStorageoverride — unchanged power-user pin.- The number the conversation already lives on — new
deriveConversationNumberreads the latest outbound message'sfromNumber(else an inbound message'stoNumber) from the already-fetched history. Zero extra round trips, and replies stay on the same thread on the lead's phone instead of hopping numbers between sends. - Phone-numbers API (new leads only), hardened:
pickSendingNumbernow skipsenabled: falserows (released numbers stay listed withdisabledReason: "user_deleted") and prefers default-flagged, then warmed-up numbers. The live response has noisDefaultfield, so the old code always picked whatever row came back first.
Side effect: since contacts / messages endpoints still work with old keys, threads with any history send fine even before the API key is rotated — only first-texts to brand-new leads need the fresh key.
See PitchPrfct → Sending.
Billing realtime cleanup no longer crashes on unmount
Uncaught (in promise) TypeError: Cannot read properties of null (reading 'unsubscribe') fired from subscribeWithRetry cleanup in
src/store/billing.jsx. The useEffect cleanup nulled the captured
channel variable synchronously, before the async
getSupabase().then((sb) => sb?.removeChannel(channel)) closure
ran, so supabase-js received null and crashed. The channel is now
captured in a local const before nulling. Audited every other
removeChannel site — none reassigned before the async removal.
Workspace skeletons pulse and read as loading again
Email, Data, and Calendar workspace skeletons rendered static
(no motion-safe:animate-pulse) because they used bare Skeleton
leaves without the SkeletonRegion wrapper that owns the
animation. Each root now uses <SkeletonRegion label="Loading <app>">, matching the existing dial / automation / SMS pattern.
Adds the ARIA loading semantics (role="status", aria-busy, SR-only
label) these three surfaces were missing.
Under the hood
sms_eventsandsms_webhook_state(migration158,sms_realtime) — user-scoped realtime toast feed, unique per(integration_id, delivery_id), added to thesupabase_realtimepublication with 30-day retention.pitchprfct-webhook(new,verify_jwt=false— token + HMAC is its auth) andpitchprfct-api(5xx logging) — both deployed to prod.useSmsRealtimesubscribes each open SMS surface tosms_eventsand drives targeted cache invalidations (conversation list, thread) — same pattern as the Gmail push wire.smsProviders.jsx— shared catalog wired into both the SMS app's picker and the dialer SMS tab's picker.SmsWorkspace/SmsInboxRail— the native surface shell plus the full-height inbox rail with tabs, search, pagination, and star toggles.email-apiv18 deployed — folder="all" mode, Gmail vs Graph URL construction pulled to pure helpers with unit tests.src/store/email.jsx— selection source (user vs auto) is now first-class in the store; refetches preserve selection.
What's next
- The Project88 SMS provider card lights up when the first-party SMS lane ships — same picker, same inbox, no data migration.
- Microsoft Graph push notifications for real-time inbox on Outlook
accounts (Gmail already lands via
gmail-webhook). - Retire the poll fallback on the SMS surface once webhook uptime hits parity with Gmail push.
See SMS app, PitchPrfct integration, and Dial → SMS / Email tabs.
0.7.0 — Data-table totals row, formula-column filter fix, no more rows lost past the 1000-row cap
Every data-table view gets a per-column summary footer with type-aware aggregations (Sum, Avg, Min, Max, Median, Earliest, Latest, Most common, %) scoped to the filtered rows and saved per view. A view filtering on a formula column now matches; edited views no longer stay dirty forever after saving. And user tables with more than ~1000 active rows no longer silently drop the tail — allocator moves to a DB trigger, list walks the whole set with a stable tiebreaker, and the footer flags partial results.
0.5.2 — One settings-modal chrome across Create/Edit View + Edit Table, persistent select options, notification-freeze fix
Create View, Edit View, and Edit Table now render on the same shared `ModalShell` — centered, solid `bg-background` panel with tinted config rail, header subtitle, pinned footer, and popover-aware Escape. Creating a select option from a cell now persists to the column, so filter pickers everywhere see it. And the grid livelock that froze the app after clicking a record-created notification is fixed.