Dial
Bucket-based outbound calling — work through a queue of contacts with scripts, call notes, and live activity tracking.
Dial is the outbound-calling mode. Where SMS is a thread-based inbox-style interface, Dial is a bucket-based queue: you organise leads into buckets, the dialler advances through them, and every call records activity, notes, and follow-up state.
Dial opens as a native shell surface — full-bleed, no React Flow
canvas underneath. The surface is URL-routed:
?bucket=<id> absent → the landing page,
present → the dial session. URL-as-state buys back / forward,
refresh-in-place, and makes "back to buckets" a param deletion
instead of a mode flag.
The dialer body lives in DialerBody and is mounted by
DialerSurface (native-apps/registry.js); the legacy
DialWidget node type is still registered so any canvas you've
already populated with a Dial widget keeps rendering. See
Canvases and widgets → native apps vs canvases.
On the native surface, while a lead is actively loaded the
contact card lifts out of the columns row into a full-height
left sidebar (matching the Calendar and Data native layouts), and
the dialer header rides the chrome's darker top bar over the
remaining columns via the shared NativePaneChrome. Empty / closed
/ error states have no contact card, so DialerSurface falls back
to the plain header-over-content stack. The canvas mount
(DialWidget) keeps the contact card inline as the first column —
the full-height-sidebar arrangement is the native opt-in
(nativeLayout).
The contact column's resize is wired against the right container: in native layout the sidebar's parent is the surface itself (it lives outside the columns row), so the resize clamp measures the whole surface; the lead-details and scripts resizes drop the contact-card term from their max-width math when the contact column isn't in the row.
Dial's outbound calling uses Telnyx phone numbers; its SMS tab is a per-lead surface built on the same PitchPrfct provider as the native SMS app. Dial is bucket-based volume-outbound; SMS is thread-based 1:1.
Buckets
A bucket is a saved table view the dialer works through. It
links to one filter_presets row (dial_buckets.preset_id → filter_presets) for its filter tree, sort priority, and source
table; the bucket row itself carries only the dial-specific
settings: schedule, per-record cap, cooldown, field map, detail
columns, and eligibility rules. Same filter language everywhere —
the view you see in Data is the queue you dial.
Buckets live in src/store/dialBuckets.jsx and the Bucket Builder
Sheet (DialBucketBuilderSheet.jsx) is where you configure them:
pick or promote a view for the Source, set priority, set today's
quota, cadence, eligibility rules.
The sheet header reads Edit Bucket when editing an existing
bucket and New Bucket when creating; saving closes the sheet
cleanly (the internal reset effect gates on a false → true
isOpen transition, so parents re-emitting a fresh initialConfig
after save no longer snap the user back to the Source step
mid-close).
Save flows from the builder sheet are wired straight into the live dialer:
- Create → auto-switch. Saving a brand-new bucket flips
activeBucketIdto the new one; the source-table effect loads its rows and the cursor lands at the top — no extra click to start dialling. - Edit the active bucket → advance. Saving cap / cooldown /
cadence edits to the bucket you're currently dialling flashes the
lead-advance skeleton and calls
handleAdvanced()so the change takes effect with the next lead. Edits to other buckets don't touch the active dialer. - Edit an eligibility rule → live refresh. Adding, editing, or
deleting an
eligibility_rulesrow publishes on the neweligibility_rules_changedactivity-bus channel, which drives a metadata refetch in every open dialer scoped to the same org (and, for bucket-scoped rules, the matching bucket). A freshly-added "180-min cooldown on No Contact" rule now starts filtering on the next cursor move — no operator-side disposition required to wake the metadata up.
Landing page: card gallery + bucket folders
The default state of the Dial surface is a card landing page —
folder-tree rail sidebar + card gallery, shaped like Templates. You
land here whenever ?bucket= is absent; entering a session sets
the param, exiting deletes it. DialBreadcrumb
(Dial › folders › bucket) replaces the old bucket dropdown as the
primary switcher: root walks back to the landing page, ancestors
return home already scoped to the folder. BucketSelect survives
only on the canvas mount, which has no landing page behind it and
would otherwise lose its only bucket switcher.
Smart scopes — the rail's top pills (All / Active / Paused / Draft / Completed) filter the whole tree at once. Folder scopes browse the tree like a file manager.
Card contents. Each card leads with available now, then
total leads and calls today · by you. A status pill distinguishes
draft from live buckets; a Closed until Mon 9:00 AM chip fires
when the schedule has the bucket shut; a "can't be dialed" chip
sends the click into the builder instead of into a dead session.
Counts that haven't loaded render —, never 0 — a card claiming
zero available leads when it simply hasn't loaded would send a rep
to the wrong bucket.
available_now is an upper bound by design.
dial_buckets_overview(p_specs jsonb) (migration 172) copies
dialer_next_leads' CTEs verbatim — cap, cooldown, cadence ladder,
teammates' leases — so the card can't promise leads the queue
won't serve. It deliberately does not run the dialer's JS
post-filters (per-lead timezone windows, eligibility/DNC, live
appointments); doing so would mean N lead-page, eligibility, and
calendar fetches on a landing page. The closed-hours chip is the
visible caveat when the schedule would zero the queue anyway.
p_specs carries each bucket's resolved view snapshot rather
than the bucket's stored flat filters, because those are the
legacy fallback path (see
Session snapshot)
and can be stale.
Folders — dial_buckets gets a tree. Migration 171 adds
parent_id / is_folder / icon / sort_order, mirroring
message_templates. Containment trigger, cascade FK, and a
folder-shape CHECK whose branches can't evaluate to NULL (a CHECK
passes on NULL — the trap already bit the templates table once).
Sort-order math is lib/treeSortOrder, already shared with Pages
and Templates. Folder deletes send an explicit descendant id list
rather than leaning on the cascade, so the delete policy runs per
row.
End of pass: no more last-lead loop
Dispositioning the last callable lead used to wrap the cursor back to the top and re-serve leads still under their daily cap. On a one-lead bucket, that was the same person, forever, with nothing on screen saying the pass had ended.
resolveAdvanceTarget(queue, cursorIndex, hasMore) in
dialBucketRuntime is a pure function returning next or
exhausted. The hasMore guard is load-bearing: the JS
post-filters routinely empty a loaded page while callable leads sit
deeper in the keyset, and calling that "done" would kick an operator
out mid-shift.
- Native mount — exhaustion returns to the landing page with a toast.
- Canvas mount — renders a Queue finished state with an explicit Start another pass button. The second lap the old code took silently now sits behind a click.
passComplete is session state that dies with the component,
never a persisted past-end cursor.
The dialer's seven historical "nothing to dial" panels — cluttered
copies of the same markup — collapse into one DialTerminalState
component, which is what made adding "Back to buckets" to every
dead end a one-line change.
View-driven filters and sort
The bucket's filter tree and sort priority come from the linked saved view — not from a parallel filter system on the bucket row. This closes the historical gap where the bucket's own filter language was flat-AND-only and lacked several operators, so a view like Ghosted — Last week would return a different set from the same filters typed into a bucket.
- One filter language. The view AST (AND / OR / NOT, the full
table operator set) is translated to SQL by
app_hidden.filter_tree_predicate(migration 152) with rule-for-rule parity to the JSapplyTableFilters. 35 golden parity fixtures keep the two engines aligned, including case-insensitiveequalsvs case-sensitiveis, JSNumber()coercion,String(array)joins, and the epoch-0 null-dateis_beforequirk. - What's blocked at attach.
child-link,formula, andlinkrules aren't translatable to a single-row predicate (they'd need joins or a fully-materialized column set), so the translator raises if they ever reach SQL and the Source picker refuses to attach a view containing them. - Org-scoped views only. A bucket may point at any view its members can see — but the Source picker requires the view be scoped to the org so a bucket doesn't end up sourced from a private view another operator can't see. Private views expose an inline Promote to org action right in the picker.
- Refuse-delete. Deleting a view a bucket uses is refused
(
ON DELETE RESTRICT) with a clear message pointing at the bucket. Detach or repoint the bucket first.
Session snapshot and mid-session view edits
The dialer snapshots the view at session start — validated,
relative date periods (this_week, last_week, …) resolved to
absolute instants — and passes that snapshot to dialer_next_leads
/ dialer_queue_stats on every call. A mid-session edit to the
view never yanks leads from under an operator: your queue
stays as it was at start until you explicitly refresh.
An in-header banner appears when the underlying view changes during a session with a Refresh queue action. Clicking it re-snapshots and repages the queue with the new definition; ignoring it keeps you on the original snapshot.
Bug fixed by construction
dialer_next_leads used to read filters off the bucket row, so a
stored relative range ({period:"this_week"}) silently matched
zero rows — the "QUOTED Last Week" bucket effectively wasn't a
bucket. Filters now arrive pre-resolved from the client, so the
same view that returns N rows in Data returns N rows in Dial.
Data migration
Every existing bucket was converted to an org-scoped view and
linked, after confirming 0 membership diffs old-vs-new per
bucket, then re-verified against prod post-conversion. Migrations
151 – 153 (schema, translator, RPC signature) are already applied.
The RPCs fall back to the legacy flat bucket filters when a client
doesn't pass a snapshot, so pre-deploy clients keep working; a
follow-up will drop the fallback and the dial_buckets.filters /
dial_buckets.sorts columns.
Bug fix: tag-assignment fetch chunking
Rolled in with the same migration: client-side tag-filtered views
and counts used to undercount silently. The grid warms tag
assignments with one bulk listAssignments call across every loaded
row, but PostgREST caps un-ranged selects at 1,000 rows, and a
~900-uuid .in() list can also blow past URL length limits. Rows
past the cap looked untagged to the filter engine while their
per-row chips (fetched individually) still rendered — so a view
appeared to match far fewer rows than the same filters in a bucket.
listAssignments now fans out in 100-id chunks and paginates each
chunk, preserving the per-entity oldest-first ordering contract.
Server-side dial queues were never affected (they join
tag_assignments in SQL) — this is what made converted views
appear to match fewer rows than their buckets did.
The current on-screen queue lead is still pinned past every filter
via pinnedRowIdRef.current (intentional carve-out at
DialWidget.jsx:629-633) so a mid-call filter / rule change never
yanks the operator off a lead. The exclusion takes effect the moment
they disposition or skip. The pin is session-only — closing the
tab or refreshing clears it, so a lead can't stay carved out of its
bucket across reloads.
The pin now also covers the case where an in-call tag or field
edit itself pushes the lead out of the bucket's filters. Since the
queue is server-paged (dialer_next_leads applies the tag / column
filters in SQL) and refetches on the minute tick and on disposition,
an edited row that no longer matches the filters used to be dropped
from the next page entirely — past the point where a client-side
pin could protect it. useDialQueue now consumes pinnedRowIdRef
and carries a missing pinned row over from the previous page on
first-page refetches, bucket-scoped (a bucket switch can't smuggle
the old bucket's row into the new page). The row falls out naturally
on the first refetch after the pin moves — same anti-yank contract
every client-side filter (cap / cooldown / schedule / eligibility /
active-event / presence) already honors.
The Fields step's "Lead Details panel — pick columns to display"
grid prefixes each pickable column with the same type icon
(COL_TYPE_META) that the live Lead Details panel uses, so the
builder and the panel visually match. The Sort step keeps its
GripVertical since that's a real drag affordance, not a column-type
indicator.
The Review step shows the bucket's full shape before save with
readable filter chips (tag UUIDs resolved to names via useTags,
same-operator tag filters grouped into one row), every active sort
(not just the first), the bucket's timezone, the live eligibility
rule count (fetched for existing buckets, derived from drafts for new
ones), and a jump-to-step Edit button per section.
The Bucket List widget (DialBucketListWidget.jsx) is one of the
dashboard-style widgets that remain canvas-addable — drop it on Home
or any board to surface every bucket with:
- Bucket name and icon
- Today's call count vs. quota
- Recent-calls preview
- Click to open / start dialling
Range operator on date filters
Date and datetime columns expose an in period (range) operator
in the bucket filter UI, rendered with the shared RangeValueInput
(period dropdown + custom from/to). Five built-in periods —
This week, Last week, This month, Last month,
Custom range — and the boundary math runs once per pass via
precomputePeriodRanges, so the window stays stable across all rows
in a single evaluation. The same primitive backs the data-table
widget's date filter (see Data app → range filter).
Ghosted-leads rollup
System datetime column on every user_table_rows row, populated by an
AFTER trigger on calendar_events (migration 122). It holds the
MAX(start_at) of no_showed events on the lead that haven't been
superseded by a later attended event or a later upcoming
pending event — i.e. it tracks open ghosts only. Once a lead
recovers (attended) or rebooks (upcoming pending), the rollup clears
to NULL automatically. A partial index (table_id, last_open_no_show_at DESC) WHERE NOT NULL keeps the index lean since most leads never
ghost.
Pair it with the range operator to build buckets like
Ghosted — Last week (Last ghosted (open) · in period ·
Last week) with no code change, just UI config.
Eligibility rules
The Cadence tab's EligibilityStep lets you stage blank rules locally
until they get a trigger_disposition_id, then inserts on commit —
Add rule never fires a draft insert against PostgREST's NOT-NULL
constraints. Existing rules are inserted/updated as you go.
Saved rule changes broadcast on the eligibility_rules_changed
activity-bus channel, so every open dialer scoped to the same org
(and the matching bucket, for bucket-scoped rules) refetches its
eligibility metadata immediately. The change takes effect on the next
cursor advance, not on the operator's next disposition.
Attempt pacing and freshness mix
Two opt-in gates on the bucket row that answer the same complaint — "I've been calling this person for three months, but I know I have people in here that are more recent." Both are off by default (empty ladder + empty mix); every existing bucket is byte-identical until someone turns them on, and clearing either field instantly restores the rows they were suppressing (both features are pure, evaluated at queue time, no writes).
Attempt pacing — dial_buckets.call_cadence (migration 165). A
ladder of tiers, each of which says "once a lead has been called
this many times, rest it for this long before it's callable
again". The highest tier at or below a lead's lifetime call count
wins; retire: true at the top of the ladder is a hard stop that
takes the lead out for good:
[{"after_calls": 0, "rest": "P1D"},
{"after_calls": 3, "rest": "P3D"},
{"after_calls": 8, "rest": "P14D"},
{"after_calls": 20, "retire": true}]callCadence.js mirrors the SQL for the builder's live preview.
The cooldown's recent CTE widens to attempts (max(occurred_at)
per row) so one CTE feeds both the cap-per-day gate and the ladder
gate, lower-bounded by least(cooldown window, now() - longest rest)
— as cheap as the old bounded CTE, because a lead with no attempt
in that window is older than every rest tier and passes them all.
dialer_next_leads returns total_calls alongside the row so the
call-history chip can render 12 calls without
a second round-trip. lead_call_summary.calls_7d / calls_24h are
stale by construction — they're only recomputed when a new call
lands on the row (migration 118), so a lead you stop calling keeps
its count forever; the ladder is built on total_calls (recomputed
absolutely) instead.
Freshness mix — dial_buckets.queue_mix (migration 166). Reads
as "every Nth call comes from leads created more than X days
ago":
{"aged_after_days": 30, "every_nth": 3}No SQL for the split. dialer_next_leads already accepts an
arbitrary filter tree, so useDialQueue pages two streams
through the same RPC — the session's view tree AND'd with
created_at is_after / is_before a cutoff — and interleaves the
pages. Cap, cooldown, the ladder, leases, schedule, and eligibility
apply per stream, untouched. Because the streams partition the same
set, dialer_queue_stats.matching_count stays correct with no
change, and the mix can never surface a lead the unmixed queue
would have refused.
The interleave anchor is calls made, not row position: the
operator always sits at the head of a queue whose head is consumed
every call, so a pattern on array index would park the aged lead at
slot N forever and never reach it. is_before and is_after are
strict in both evaluators, so the two streams overlap by 1 ms at
the cutoff rather than meeting exactly — a lead created on the
boundary would otherwise appear in neither. The merge de-dupes, so
overlap is free; a gap would be invisible.
Pinned rows and header-searched leads bypass both, exactly as they bypass cap, cooldown, and leases.
Also fixed here. min_minutes_between_calls was silently
dropped on bucket creation — the value in the builder landed on the
column default until the first edit saved it.
The Dial widget
The main DialWidget.jsx is the actual dialling surface. It shows:
- Header search — type a name, email, or phone number to jump to any lead across every bucket (see below)
- Calls-today count — the header carries a plain "N calls
today" count next to the bucket selector. It reads the unclamped
per-bucket total that already backs the bucket dropdown, so header
and dropdown can't disagree. This replaces the earlier
daily-progress bar, which measured against a synthetic
matching_count × capbudget that moved whenever the queue did (so it could run backwards) and whose numerator was clamped per record — understating the day whenever a lead was dialed past the cap. - Next-meeting badge — a live countdown chip in the header
showing the operator's soonest upcoming linked meeting, sitting
left of the calls-today count. Format adapts to how close the
meeting is:
mm:ssunder 5 min,in 14munder 1 h,in 2h 14munder 24 h,Tomorrow 9:00 AMfurther out, andNowwhile in progress. Tick cadence is 1 s when imminent and 30 s otherwise — owned by the badge itself so the rest of the header doesn't re-render every second. Clicking the chip loads the linked lead into the dialer and pins it past the active-event filter so the row lands in the queue even when its own appointment would normally hide it. The pin is session-only and clears on disposition / skip — refreshing the tab drops it (this is the only surface that still pins; header search no longer does). Backed by thenext_upcoming_appointment_for_current_userRPC (migration124) —SECURITY INVOKER, scoped server-side toauth.uid()so callers can't peek at other users' calendars. Live updates ride the existingeventLinkBus+ a 60 s safety refetch (calendar_eventsisn't on thesupabase_realtimepublication). - Current contact — name, company, last-contacted-at, conversation
count, plus a call-history chip that reads
12 calls, orAged · 12 calls · created 3mo agowhen the freshness mix served the lead from the aged stream — so when an old lead surfaces, the operator can see the queue chose it deliberately. Backed bytotal_callsoff the queue row; a header-searched lead comes from the store with no count and renders as unknown rather than as zero. The contact panel's action row also carries a Message button (see Message button) and an Email action that opens the compose seeded to the lead. - Lead details panel — full row from the People table. Each field
label is prefixed with the column's type icon (phone, envelope,
hash,
$, calendar, etc., sourced fromCOL_TYPE_META) so the shape of the row reads at a glance. Double-click any cell to inline-edit: text / number / phone / email / price / percent inputs auto-focus with their value selected; select, multi-select, link, and tags pickers auto-open on first double-click (one-click pick); date / datetime / time triggers focus and open on Enter or Space; boolean focuses its first option button. Double-clicking inside an active input is a no-op so word-select doesn't reset the draft to the original value. Columns whose type isn't inCOL_TYPE_META(e.g.json) render without an icon and don't break layout. Sensitive columns (see column-level encryption) render masked with an Eye / EyeOff toggle — clicking the eye calls the auditeduser_table_row_reveal_sensitiveRPC to decrypt a single value, and double-clicking after reveal opens the inline editor as normal. Advancing to the next lead re-masks every sensitive field automatically; nothing decrypted leaks across leads. - Scripts panel — pre-written talk tracks you can step through.
A
TABSmap dedupes the tab-button strip across the panel's Scripts / Calendar / SMS / Email / Tasks tabs (see Tasks tab). - Call note composer — draft notes during the call; auto-saved as
drafts via
useDialNoteDraftand persisted on completion. The input sits in the samefocus-withincard chrome as the record-sheet activity composer, so the two reads as one primitive. - Result rail — the call outcome (Connected / No answer / Voicemail / Wrong number / etc.) that drives where the contact goes next. The manual advance button now reads Skip (matches the tooltip + the action's intent).
- Activity panel — recent calls and events for the current lead
only. Backed by
useRecordActivities, which captures the active(rowId, tableId)per refetch and drops late-arriving writes from the previous lead — so dispositioning a call and advancing never shows the prior lead's activity on the next screen. Calendar events linked to the lead surface as meeting rows with an inline event preview button; clicking opens the sharedEventDetailSheet(see Records → calendar event lifecycle activities). The linked-record chip on those rows is clickable — the panel threadsonOpenRecordthrough to the sheet so tappingprospects · Namejumps into the record — and the meeting disposition picker inside the sheet applies instantly rather than reading "— No disposition —" until a refetch lands (useRecordActivitiessubscribes todisposition_updatedand patches the matching activity plus its hydrated event in place, so the card, the calendar chip, and any cache-mirror slot all track the write). - Calendar tab — schedule a follow-up directly from the call.
Events created from a slot click in this tab are auto-linked to the
active lead (
calendar_event_linksrow created in the same transaction as the event), so the existing active-event filter sees the new appointment and suppresses the lead from the queue without a manual relink step. - Email tab — every Gmail / Outlook thread to-or-from the current
lead's email address, listed by subject + last-message snippet + label
chips + time, with a drill-in reader that reuses the inbox's paper-card
visuals. The tab searches all mail (Sent included), not just the
inbox — so a lead who's been emailed but hasn't replied surfaces
every outgoing thread as well as the empty state used to hide. A
persistent header sits above the thread list with New Email and
Refresh — the header survives every body state (loading,
load-failure, empty), so a thread list that failed to load isn't a
reason to be unable to write. New Email opens the
floating compose
seeded to the lead, with the compose toolbar's template picker
wired to the lead so
{{contact.*}}merge tags resolve on insert (the picker used to mount with no contact, so every tag rendered unfilled). Refresh is manual on purpose —sendEmaildoesn't touch the React Query cache and a provider lags in surfacing a just-sent message, so an automatic post-send refetch would race and show nothing. The drill-in reader header carries Reply / Reply All / Forward ghost buttons that open the floating composer with a blank body (no>>>>>quote chain) via anoQuoteoption onbuildReplySeed. Forward emits the standard "Forwarded message" block with an emptytoand a nullreplyTo— carryingreplyTowould thread the forward back into the original conversation viaIn-Reply-Toheaders. Attachments can't ride along on a forward (a draft can only send objects already in the bucket under the sending user's prefix), so the Forward warns instead of silently dropping the contract someone meant to forward. All three entry points — the tab's New Email header, the reader actions, and the contact-panel Email button — buildrecordContextthrough the shareddialEmailCompose.js, so every send bumpslast_activity_atand logs against the record (the contact-panel button already got this right; the tab used to skip the insert, and the bucket queue would re-serve a lead that had just been emailed). State is isolated from the main Inbox — opening a thread here doesn't touch the inbox'sactiveThread, search, or folder. The hook (useDialEmailForLead) is React Query–backed: it fans out across every connected email integration in parallel withfolder: 'all', merges + dedupes results, and caches the per-lead thread list so re-visiting a lead reads from cache instead of re-fetching. Thread metadata is prefetched on lead advance (viauseDialPrefetch) and thread bodies prefetch on hover —onMouseEnter/onFocuson each email row warms the cache before the click. Per-integration page size is 100 results to cover the long-tail without paginating. - SMS tab — a single-conversation view for the current lead, backed
by an API-key SMS provider. Composer carries a Templates picker
(same
TemplatePickerPopoverused by the SMS app and email compose) — merge fields resolve against the active lead on insert. Image attachments render as image bubbles in the thread; Copy message carries them as base64 data URIs so a paste into the Messages app reproduces the template. See Templates app. A two-card picker (shared with the SMS app viasmsProviders.jsx) exposes PitchPrfct (active) and Project88 (coming soon); selecting PitchPrfct opens the conversation via thepitchprfct-apiedge-function proxy and a reusable<SmsMessageBubble>. TextDrip has been removed — it was a placeholder that never shipped. Four pre-conversation gates (no phone, unparseable number, connection loading, not connected → opensApiKeyConnectModal) plus three in-conversation error states (auth → Reconnect, rate-limit → inline retry banner, generic → "Try again") cover the full state machine. Sending resolvesfromNumberin three steps (see PitchPrfct → Sending): thelocalStoragepower-user override first; then the number the conversation already lives on (derived from the message history with zero extra round trips, so replies stay on the same thread); then the phone-numbers API as a new-lead fallback. Upstream error messages (opted-out contact, insufficient credits, no sending number, …) are extracted and carried onto the failed bubble's retry tooltip, so a failure explains why instead of rendering a bare "Failed". The composer mirrors the record sheet's Log-activity pattern (Enter-to-send). Bubbles render withwhitespace-pre-wrap break-wordsso multi-line composer drafts keep their newlines and long URLs wrap instead of overflowing. The stream groups messages by calendar day with date dividers —Today/Yesterday/May 26th, 2026-style labels — and inbound messages now push in through the PitchPrfct webhook →sms_events→ Supabase Realtime pipeline (useSmsRealtime), with a 20 s poll retained as a fallback. SMS history loads 200 messages per fetch. See PitchPrfct integration.
Message button
The contact panel's Message button on the dial widget opens a template menu instead of firing immediately. Blank message is the first row (today's behaviour); below it, every SMS template rendered against the lead so merge tags resolve before the text leaves the app. Picking a row hands off to the OS texting app with the body prefilled.
- The URI is platform-branched. RFC 5724 specifies
sms:NUMBER?body=…and Android follows it, but Apple's Messages drops the text unless the body is introduced with&. There's no single string that works on both, so we branch rather than ship the?&body=hack that's meant to satisfy both and quietly fails on some versions of each. - The text is also copied to the clipboard. Prefill is best-effort by nature — URL length limits, desktop handlers that ignore the parameter — and when it fails the rep pastes instead of retyping. The menu footer says exactly that, so the clipboard write isn't a surprise.
- Nothing here is logged. No conversation record, no activity row, no queue bump — that's inherent to leaving the app. The in-app SMS tab remains the path that records a real send.
The same TemplatePickerPopover primitive backs this menu, the
Email compose picker,
and the SMS app composer — see
Templates app.
Tasks tab in the Scripts panel
The Scripts panel gains a Tasks tab (peer of Scripts / Calendar /
SMS / Email) listing every task linked to the current lead, grouped
Overdue / Open / Completed. Unlike the org-wide TasksWidget
(which windows to 7 days), the per-lead tab shows the whole plan —
on a single lead, the entire list matters.
- Rows reuse
TaskRow/SectionfromTasksWidgetand read throughuseRecordActivities(['task']), so toggling a task here syncs the top-bar Tasks pill, the org-wide widget, and the calendar's task chips via the shared task bus. - A quick composer at the top of the tab takes a title and an optional due date / time. The draft resets on lead advance so a task typed for one lead can't land on the next.
- The per-lead LRU cache in
useRecordActivitiesmakes lead switching instant on second visit.
useDialAdvance handles the queue logic — finishing a call advances to
the next contact in the bucket, respecting the result rail's branching.
The page of candidate leads itself comes from useDialQueue (server-paged
via dialer_next_leads); see
Server-side queue and authoritative leasing.
After a disposition or skip, the Contact / Lead Details / Activity
panels hold a layout-matched skeleton for the 350 ms min-display
window (and longer while recordDialActivity is in flight), so the
next lead reads as a deliberate beat rather than an imperceptible flip.
The Call History sheet also opens onto a skeleton list mirroring
CallHistoryRow and re-flashes the skeleton on filter changes.
SMS / Email cache and prefetch
The SMS and Email tabs sit on a shared React Query cache mounted at
the app root (QueryClientProvider). Switching tabs or returning to a
recently dialed lead reads from cache instead of re-fetching — first
click is near-instant. Behavior:
- Per-data-type
staleTime. SMS and email queries are tuned so the cache survives lead advance and tab switching; the PitchPrfct connection check usesstaleTime: Infinityand is shared across every surface that needs it, so remounting the SMS tab doesn't re-issueapi.integrations.list(). - Tiered prefetch on lead advance.
useDialPrefetchfires when the cursor moves to a new lead and warms the cheap RPCs in the background — SMS contact + messages, plus email thread-list metadata. Thread bodies stay lazy and prefetch on hover. - Optimistic SMS sends. Outbound messages render an optimistic
bubble immediately; the cache is updated directly via
queryClient.setQueryDataso the bubble survives the realtime / fallback-poll cache invalidations until the server confirms.
Header search (Search mode)
The header search sits next to the bucket selector and is the operator's escape hatch from the auto-cycle queue. Picking a result opens an ephemeral Search mode that displays the lead without touching the underlying bucket queue.
- Cross-bucket. The search input scans every distinct source table
any bucket points at (eager-loaded via
ensureRowsLoaded). Matches from the current bucket are listed first; matches from other buckets appear under an Other buckets section header. Picking a result switches bucket if needed and jumps to the lead. - Phone normalization. Phone-typed columns get a digits-only
fallback so
5207109378matches a stored(520) 710-9378. The samelib/searchMatch.jshelper now backs both dialer search and data-table search. - Always-on appointment filter. A server-side RPC
(
dialer_active_events_by_rows) joinscalendar_event_links⋈calendar_eventsonend_at > now()to skip leads with an active linked calendar event — across every bucket, with no time horizon. On RPC failure the dialer fails open with a visible amber "Couldn't check appointments" banner so the operator knows the safety net is down. - Search mode is ephemeral — it does not pin. Picking a search
result sets
searchedLead = { bucketId, rowId, outsideFilters }rather than pinning the row into the queue. The lead displays via the bucket's render config (field map, scripts, detail columns) but never enters the queue and never bypasses filters, cap, cooldown, or the active-appointment check. A header Search pill shows the lead name, an "outside filters" hint when the row wouldn't normally match the current bucket, and an exit button back to the queue. - Disposition / skip returns to the queue. Recording a result against the searched lead writes the activity against that lead, then drops the operator back into the bucket's auto-cycle where they left off — Search mode clears automatically.
- No persistence across reloads. Header search no longer writes
to localStorage. Reloading clears Search mode entirely; the cursor
restores to the bucket's queue position only. Legacy persisted
pinnedRowIdsrecords from older clients are ignored on read so no lead can stay trapped past a filter change after a refresh.
Server-side queue and authoritative leasing
The dial queue is computed in Postgres, not in the browser. The client used to bulk-load every row in the source table and apply filters / cap / cooldown / sort in JS — fine at a few thousand rows, unworkable past that. As of migrations 142–146 the next-lead algorithm runs server-side, paginates with a keyset cursor, and reserves leads with an authoritative per-row lease so two operators on the same bucket can't double-dial.
dialer_next_leads — server-side queue (migrations 142, 143, 146, 152, 153)
app_hidden.filter_tree_predicate(migration 152) compiles the full view AST (AND / OR / NOT, the complete table operator set) to aWHEREclause built withformat(%L), replacing the flatdialer_filter_predicate(migration 142) that the bucket's own filter language used to feed. The bucket now sources its filters from a linked saved view (dial_buckets.preset_id → filter_presets, migration 151) — the same view you can open in Data returns the same rows in Dial. Rule-for-rule JS parity is enforced by 35 golden fixtures.dialer_next_leads(migrations 143 / 146, updated by 153 to accept a view snapshot) is the page-fetch RPC: it applies the view's filter tree, the bucket's sort / cap / cooldown, the archived-row exclusion, and the teammates'-lease exclusion server-side, paginates with a keyset cursor, and accepts aninclude_row_idscarve-out so a session pin (mid-call lead or next-meeting badge) still surfaces past every filter. The RPC returnsdata_encalongsidedataso the panel can mask sensitive fields without a second round-trip.dialer_queue_stats(migration 145, updated by 153) still returnsmatching_count(size of the filtered queue) and a cappedcalls_today. The unclamped per-bucket total that backs the header calls-today count reads through a separate path (the same one the bucket dropdown uses), because the clampedcalls_todayunderstated the day whenever a lead was dialed past the cap.matching_countis still the size of the full queue; the freshness mix partitions that same set into two streams, somatching_countstays correct with the mix on and neither stream can surface a lead the unmixed queue would have refused.- Both RPCs still fall back to the legacy flat bucket-filter path
when the client doesn't pass a snapshot, so pre-deploy clients
keep working; a follow-up will drop the fallback and the
dial_buckets.filters/dial_buckets.sortscolumns. - All three RPCs are
SECURITY INVOKERso RLS continues to enforce org / table scope; no new security-advisor warnings.
useDialQueue — keyset-paged client hook
useDialQueue replaces the old bulk ensureRowsLoaded for the dial
loop. It fetches a page from dialer_next_leads, holds the keyset
cursor, fetches the next page automatically as the operator advances,
and supports includeRowIds for the session-pin carve-out. Writes
during a call (patchRow) update data and data_enc in place so
the active lead's masked / revealed fields stay coherent across
inline edits.
Schedule, eligibility, and the active-event filter remain JS
post-filters over the page — they're cheap to evaluate per row and
they keep the cursor reactive to in-session changes. Header search
and viewing an off-queue record still rely on ensureRowsLoaded
for now; a follow-up server-side search RPC + lazy record fetch will
retire that path.
Authoritative leasing — dialer_lead_leases (migration 144)
Multi-agent coordination is now an authoritative server-side lease, not a presence broadcast.
dialer_lead_leasesis a per-(bucket, row)lease row with a TTL.dialer_claim_leadis a conditional upsert: exactly one caller wins under concurrency. The dialer claims the lead on dial.dialer_release_leadreleases the lease on advance or disposition. The widget also heartbeats every minute so a crashed tab can't hold a lease forever — leases expire on TTL.dialer_next_leadsalready excludes teammates' active leases from the page, so a leased row never appears in another operator's queue. The "entire-call double-dial" race that the old presence soft-lock could only narrow to sub-second is now closed at the database.
The Supabase Realtime presence channel is retained for the live "contested" UI chip only — if two operators briefly contend for the same row in the same tick, the loser sees an amber "Also dialing: ‹name›" indicator while the lease resolves. State is held in the lease table; presence is only a hint for the chrome.
Activity sync (unchanged)
useRecordActivitiesRealtime still subscribes each open dialer to
record_activities filtered by the bucket's source table and bridges
remote INSERT / UPDATE / DELETE events into the activityBus,
so today-counts, cooldowns, and the activity feed update within ~1 s
without a manual reload. Requires migration
121_record_activities_realtime_publication.sql.
Pin / anti-yank contract
The mid-call session pin (pinnedRowIdRef.current) still
protects the active queue lead from filter changes — include_row_ids
in dialer_next_leads carves the pinned row past the server-side
predicate, and the JS post-filters (schedule, eligibility,
active-event, bucket-filter tree) honour the same pin. An in-call edit
that flips the current lead out of the filter tree — e.g. adding a
tag that matches tags not_has DNC — keeps the lead on screen until
the operator dispositions or skips, at which point the pin clears and
the now-excluded row drops on the next page fetch.
Search mode displays a lead without ever entering the queue, so it
doesn't participate in the pin contract. record_activities remains
the authoritative source of truth for what's been dialed;
dialer_lead_leases is authoritative for what's currently being
dialed.
The Dial Stats widget
DialStatsWidget (in dial-stats/) renders calls-today, conversion, and
a disposition breakdown for a chosen bucket. Each widget carries a
per-node user scope — persisted on the canvas node — so a single
canvas can show multiple scoped views side by side:
| Scope | What it counts |
|---|---|
| Everyone | All calls on the bucket (default — backward compat) |
| Me | The viewer's own calls. Resolves at render time, so a shared canvas shows each viewer their own numbers |
| A teammate | Pick any org member from the UserPicker; an avatar/initials chip renders next to the stats |
- Drill-through inherits the scope. Clicking a disposition row
opens the history sheet showing only the scoped user's calls,
backed by a nullable
p_user_idargument ondial_bucket_calls/dial_bucket_today_calls(migration 117). Counts in the widget and rows in the sheet always match. - Activity-bus stays scoped. The activity-bus payload carries
createdBy, so a scopeduseDialStatsskips refetches triggered by other users' activity. Two scoped widgets on the same canvas (e.g. one on Me, one on a teammate) only refetch when their own scope's calls change. - Stale userIds fail loud. If a teammate is removed from the org while a scoped widget still points at them, the widget renders "Unknown user" with zero totals and a warning chip — never silently falls back to Everyone in a way that would shift numbers without telling the operator.
- Three sizes. Small shows a glanceable total with a scope marker
overlay when scoped; Medium and Large show the full
DialStatsControlsstrip (bucket / scope / window / sheet).
The shared UserPicker primitive — built on the design-system
Select — and the org-members store (useOrgMembers) are also used
elsewhere across the app.
Components and sheets
The native Dial surface composes the same internal panels the old
widget did. The two dashboard-style widgets (DialBucketListWidget,
DialStatsWidget) are still addable to other canvases via the global
Add Widget menu; everything else is now an internal piece of
DialerBody.
| Component | Where it mounts |
|---|---|
DialerSurface / DialerBody | The native Dial app. DialerSurface owns the ?bucket=<id> route — absent → the landing page, present → the session. DialerBody is also rendered inside the legacy DialWidget node for backwards compat via optional controlled props (bucketId / onSelectBucket / onExitToHome / onQueueExhausted / initialSearchRowId); omitted, it behaves exactly as before |
DialLandingPage | Internal — card gallery + folder rail |
DialBucketCard | Internal — the per-bucket card on the landing page |
DialBreadcrumb | Internal — Dial › folders › bucket primary switcher |
DialTerminalState | Internal — the single "nothing to dial" / queue-finished panel |
DialBucketListWidget | Canvas widget — list of buckets |
DialStatsWidget (in dial-stats/) | Canvas widget — calls today, conversion, breakdown (per-user scope, see above) |
DialActivityPanel | Internal — recent activity stream |
DialContactPanel | Internal — current contact details |
DialLeadDetailsPanel | Internal — full lead view |
DialCallNoteComposer | Internal — note draft + save |
DialScriptsPanel | Internal — talk tracks |
DialResultRail | Internal — outcome buttons |
DialEmailCompose | Internal — follow-up email composer |
DialCalendarTab | Internal — inline calendar for scheduling |
DialEmailTab | Internal — per-lead inbox in the middle panel |
DialSmsTab | Internal — per-lead SMS conversation (PitchPrfct) |
DialTasksTab | Internal — per-lead Overdue / Open / Completed tasks + composer |
DialCallHistorySheet | Modal — full call history |
DialBucketBuilderSheet | Modal — bucket configuration |
Dashboard widgets compose with anything else on a canvas — pin a
DialStatsWidget next to a CalendarAgendaWidget and a
DataStatsWidget on Home to build a per-rep overview without leaving
the launchpad.
Where Dial gets its leads
The People table — same as SMS and Campaigns. Bucket configuration is a saved filter against People; new matching contacts surface in the bucket automatically as they're added.
Setup
Voice phone numbers for the dialler are configured under Settings → Phone Numbers (Telnyx). For 10DLC and compliance settings on the Telnyx side, see the Telnyx SMS integration — note that the SMS surface itself now runs on PitchPrfct, not Telnyx.
Where to next
- SMS — the native SMS app; same PitchPrfct provider, thread-first surface
- PitchPrfct integration — the SMS provider behind the SMS tab
- Telnyx SMS integration — voice phone numbers for the dialler
- Data tables — the People table
Inbox
Multi-account email client — Gmail & Outlook, merged inbox, Gmail-style floating compose with rich text, attachments, scheduled send, signatures, and calendar inserts.
SMS
Native SMS app backed by PitchPrfct — full-height inbox rail with tabs and search, thread pane, service picker, and live messages via a HMAC-verified webhook → Supabase Realtime pipeline.