Apps

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 activeBucketId to 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_rules row publishes on the new eligibility_rules_changed activity-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.

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 JS applyTableFilters. 35 golden parity fixtures keep the two engines aligned, including case-insensitive equals vs case-sensitive is, JS Number() coercion, String(array) joins, and the epoch-0 null-date is_before quirk.
  • What's blocked at attach. child-link, formula, and link rules 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 × cap budget 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:ss under 5 min, in 14m under 1 h, in 2h 14m under 24 h, Tomorrow 9:00 AM further out, and Now while 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 the next_upcoming_appointment_for_current_user RPC (migration 124) — SECURITY INVOKER, scoped server-side to auth.uid() so callers can't peek at other users' calendars. Live updates ride the existing eventLinkBus + a 60 s safety refetch (calendar_events isn't on the supabase_realtime publication).
  • Current contact — name, company, last-contacted-at, conversation count, plus a call-history chip that reads 12 calls, or Aged · 12 calls · created 3mo ago when 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 by total_calls off 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 from COL_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 in COL_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 audited user_table_row_reveal_sensitive RPC 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 TABS map 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 useDialNoteDraft and persisted on completion. The input sits in the same focus-within card 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 shared EventDetailSheet (see Records → calendar event lifecycle activities). The linked-record chip on those rows is clickable — the panel threads onOpenRecord through to the sheet so tapping prospects · Name jumps into the record — and the meeting disposition picker inside the sheet applies instantly rather than reading "— No disposition —" until a refetch lands (useRecordActivities subscribes to disposition_updated and 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_links row 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 — sendEmail doesn'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 a noQuote option on buildReplySeed. Forward emits the standard "Forwarded message" block with an empty to and a null replyTo — carrying replyTo would thread the forward back into the original conversation via In-Reply-To headers. 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 — build recordContext through the shared dialEmailCompose.js, so every send bumps last_activity_at and 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's activeThread, search, or folder. The hook (useDialEmailForLead) is React Query–backed: it fans out across every connected email integration in parallel with folder: '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 (via useDialPrefetch) and thread bodies prefetch on hoveronMouseEnter / onFocus on 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 TemplatePickerPopover used 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 via smsProviders.jsx) exposes PitchPrfct (active) and Project88 (coming soon); selecting PitchPrfct opens the conversation via the pitchprfct-api edge-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 → opens ApiKeyConnectModal) plus three in-conversation error states (auth → Reconnect, rate-limit → inline retry banner, generic → "Try again") cover the full state machine. Sending resolves fromNumber in three steps (see PitchPrfct → Sending): the localStorage power-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 with whitespace-pre-wrap break-words so multi-line composer drafts keep their newlines and long URLs wrap instead of overflowing. The stream groups messages by calendar day with date dividersToday / 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 / Section from TasksWidget and read through useRecordActivities(['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 useRecordActivities makes 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 uses staleTime: Infinity and is shared across every surface that needs it, so remounting the SMS tab doesn't re-issue api.integrations.list().
  • Tiered prefetch on lead advance. useDialPrefetch fires 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.setQueryData so 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 5207109378 matches a stored (520) 710-9378. The same lib/searchMatch.js helper now backs both dialer search and data-table search.
  • Always-on appointment filter. A server-side RPC (dialer_active_events_by_rows) joins calendar_event_linkscalendar_events on end_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 pinnedRowIds records 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 a WHERE clause built with format(%L), replacing the flat dialer_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 an include_row_ids carve-out so a session pin (mid-call lead or next-meeting badge) still surfaces past every filter. The RPC returns data_enc alongside data so the panel can mask sensitive fields without a second round-trip.
  • dialer_queue_stats (migration 145, updated by 153) still returns matching_count (size of the filtered queue) and a capped calls_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 clamped calls_today understated the day whenever a lead was dialed past the cap. matching_count is still the size of the full queue; the freshness mix partitions that same set into two streams, so matching_count stays 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.sorts columns.
  • All three RPCs are SECURITY INVOKER so 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_leases is a per-(bucket, row) lease row with a TTL.
  • dialer_claim_lead is a conditional upsert: exactly one caller wins under concurrency. The dialer claims the lead on dial.
  • dialer_release_lead releases 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_leads already 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:

ScopeWhat it counts
EveryoneAll calls on the bucket (default — backward compat)
MeThe viewer's own calls. Resolves at render time, so a shared canvas shows each viewer their own numbers
A teammatePick 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_id argument on dial_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 scoped useDialStats skips 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 DialStatsControls strip (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.

ComponentWhere it mounts
DialerSurface / DialerBodyThe 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
DialLandingPageInternal — card gallery + folder rail
DialBucketCardInternal — the per-bucket card on the landing page
DialBreadcrumbInternal — Dial › folders › bucket primary switcher
DialTerminalStateInternal — the single "nothing to dial" / queue-finished panel
DialBucketListWidgetCanvas widget — list of buckets
DialStatsWidget (in dial-stats/)Canvas widget — calls today, conversion, breakdown (per-user scope, see above)
DialActivityPanelInternal — recent activity stream
DialContactPanelInternal — current contact details
DialLeadDetailsPanelInternal — full lead view
DialCallNoteComposerInternal — note draft + save
DialScriptsPanelInternal — talk tracks
DialResultRailInternal — outcome buttons
DialEmailComposeInternal — follow-up email composer
DialCalendarTabInternal — inline calendar for scheduling
DialEmailTabInternal — per-lead inbox in the middle panel
DialSmsTabInternal — per-lead SMS conversation (PitchPrfct)
DialTasksTabInternal — per-lead Overdue / Open / Completed tasks + composer
DialCallHistorySheetModal — full call history
DialBucketBuilderSheetModal — 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

On this page