0.10.0 — Dial home + bucket folders, default-From + scheduled send that ships, and phone/palette search that finds what's there
Dial gets a **card landing page with bucket folders** and stops looping the last lead of an exhausted queue. **Default From account** on every compose window — and **scheduled send** finally runs end-to-end (the worker was never deployed). **Phone search** matches by a canonical digit key instead of stored format, so `(910) 620-0001` and `9106200001` are the same lookup. The command palette stops hiding an entire org behind a legacy `workspaces` row. Clients get a real **source** column backfilled from lineage/phone/email, and the `pitchPrfct` lead-source label is renamed to `selfGen` in place — data, options list, saved views, and conversion snapshots all in one migration.
What's new
Dial: card landing page, bucket folders, and no more last-lead loop
Clicking Dial used to drop you straight into a session on whichever bucket sorted first — the only overview was a dropdown carrying one number. And a bucket that ran out of leads looped: dispositioning the last lead wrapped the cursor back to the top and re-served leads still under their daily cap. On a one-lead bucket, that is the same person, forever, with nothing on screen saying the pass had ended.
DialerSurface now routes on ?bucket=<id>: absent → the card
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.
Landing page — folder tree + card gallery. Templates-shaped
layout: folder-tree rail sidebar with smart scopes
(All / Active / Paused / Draft / Completed) that filter the
whole tree; folder scopes browse like a file manager. Cards lead
with available now, then total leads and calls today · by you. Plus a status pill, a Closed until Mon 9:00 AM chip, and a
"can't be dialed" chip whose click goes to 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.
Bucket folders — dial_buckets gets a tree (migration 171:
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 — that 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 — resolveAdvanceTarget(queue, cursorIndex, hasMore)
is pure, in dialBucketRuntime, and returns 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. On
exhaustion the native mount returns home with a toast; the canvas
mount renders a Queue finished state with an explicit
Start another pass — 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.
Available-now count. Migration 172 adds
dial_buckets_overview(p_specs jsonb) — every card's counts in one
round trip. p_specs carries each bucket's resolved view snapshot
because a view-linked bucket's stored flat filters are the legacy
fallback and can be stale. available_now 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 —
so it's an upper bound, and the closed-hours chip is the visible
caveat.
Navigation. DialBreadcrumb replaces the bucket dropdown:
Dial › folders › bucket, root always walking back to the landing
page, ancestors returning home already scoped to that folder.
Switching buckets is now "go back to the list and pick one" — the
same gesture as every other app in the shell. BucketSelect
survives only on the canvas mount, which has no landing page behind
it and would otherwise lose its only bucket switcher.
DRY under the hood. The dialer's seven "nothing to dial"
panels were seven copies of one markup block; they're now one
DialTerminalState. That's what made adding "Back to buckets" to
every dead end a single edit instead of seven.
See Dial → landing page and buckets.
Inbox: default From account, and scheduled send actually works
Two related shifts in the email surface — the compose window now always tells you which account it's going out from, and scheduled send now ships end-to-end for the first time.
Default From account. Configured in Settings → Inbox →
Defaults, stored per-user and per-org at
profile.preferences.inbox.default_from_by_org[orgId]. Org-keyed
because integrations rows are unique per (org_id, user_id, provider), so a user in two orgs has two disjoint connection sets
and a single scalar would dangle in whichever org didn't set it.
Every compose window now shows a From row when more than one
account is connected — static text on replies, which can only be
threaded by the mailbox holding the conversation. The account is
pinned to the draft at open time (DRAFT_VERSION 2→3) and always
passed at send, so what the row shows is what actually sends,
including for a draft restored after a refresh.
Resolution order. The account being viewed in the inbox → the configured default → the first connection. Both stored steps are validated against the live connections list, so a disconnected or other-org id degrades to the first connection instead of failing the send.
Bugs this exposed:
- Re-opened provider drafts captured
draftIntegrationIdbut ignored it at send — a draft written in account B could go out from account A. - Per-account default signatures already existed but never
applied to a new compose (auto-apply resolved against
replyToonly). They do now, and switching From swaps the signature — unless you picked one by hand, which the newsignatureAutoflag protects. activeViewis session-wide.EmailProviderwraps the whole authenticated workspace, so "the account you're viewing" kept naming the last account you opened in the inbox long after you'd moved to the dialer or a record sheet — meaning the configured default would never have applied there. Inbox context is now supplied by the inbox surfaces themselves (useInboxCompose) rather than read from global state.
Scheduled send — actually runs now. It had never worked. The compose UI and the create/list/cancel API were complete; everything beneath them was missing:
| Layer | Before |
|---|---|
| DB insert | integration_id is NOT NULL and compose passed null for non-replies — the INSERT was rejected. Only replies could ever be queued. |
| Worker | Never deployed — absent from all 25 edge functions. |
| Cron | No job existed — a valid row would never dispatch. |
| UI | Nothing rendered the list; failures were silent. |
The empty production scheduled_email_sends table was the tell.
Now live and verified in production: worker deployed
(verify_jwt=true, matching every other cron-invoked function),
driven by a new per-minute pg_cron job using the Vault
service-role key — the process-calendar-reminders pattern.
- Retry classification. Transient failures (provider 5xx, 429,
network) return to
pendingwith backoff up to 3 attempts; permanent ones (disconnected account, missing attachment, rejected request) fail immediately rather than delaying the bad news. Unrecognised errors count as transient — dropping a queued message is worse than sending it late. - Failures are no longer silent. Terminal failures write a
notification that rides the existing realtime fan-out. This needed
its own toast gate: the records gate suppresses
created_by === userId, which for your own failed send is exactly backwards. - Stuck rows recover. A row claimed as
sendingwhose worker died returns to the queue after 10 minutes; the claim query only looks atpending, so it would otherwise never retry. - Scheduled panel in the email rail: what's queued, when, and
why anything failed, realtime-subscribed so rows update in place.
A
Retryingstate distinct fromScheduled. Cancel reopens the message as a draft — body, recipients, From account and attachments intact. - Send-time validation in both the store and the picker. The picker previously returned silently on a past time, so Schedule just did nothing.
Storage cleanup and retention. deleteAttachment only ever
fired when a user removed a chip from a draft. Nothing cleaned up
after a successful send — not the compose store, not
email-api, not the worker — so every attachment ever sent stayed
in the bucket permanently. Both paths now delete on success. Safe
because template files are copied into a draft-owned path on
insert, so the template's own object (a different bucket) is
untouched; scheduling deliberately does not clean up, since the
worker needs the files later. A nightly purge trims terminal queue
rows — sent/cancelled at 30 days, failed at 90, since failures are
the rows you may still need to act on.
Migration 170 adds the cron jobs and the realtime publication,
and re-declares the table, enum, RLS, indexes and trigger
idempotently — all of it had been MCP-applied with no repo
migration, so a fresh environment could not be built from the repo
alone. The updated_at function is reproduced verbatim including
SECURITY DEFINER and its pinned search_path; a naive re-declare
would have silently stripped both.
See Inbox → Default From account and Inbox → Schedule send.
Phone search: canonical digit key, no more format-matching
Phone lookup only worked when the typed format happened to match the
stored format. to_tsvector('simple', …) tokenizes on punctuation,
so formatting became part of the index:
| Stored | Tokens |
|---|---|
(317) 644-9695 | 317, 644, -9695 |
+13176449695 | +13176449695 (one token) |
3176449695 | 3176449695 (one token) |
Measured against production before the fix — row stored
(910) 620-0001 was invisible to 9106200001 or +19106200001;
row stored 8478901614 was invisible to +18478901614 or
(847) 890-1614. Client-side, searchMatch tested query-digits ⊂
cell-digits — one-directional, which is exactly why adding +1
broke it.
Both sides normalize to one key. NFKC → strip extension → ASCII digits → drop NANP country code, as JS/SQL twins pinned by shared fixtures:
lib/phone.js⇄app_hidden.phone_search_key— contract inlib/phoneSearch.fixtures.js, asserted in both runtimes.- Migration 173 —
search_phones text[]column + partial GIN index, trigger extension, and a newg8arm onglobal_search.
NFKC is required, not cosmetic. JS \D deletes full-width
digits ('123' → ''); Postgres \D keeps them. Two
different wrong answers. Prod also has a row with invisible
LTR-embedding marks from an iOS paste.
Design choices worth reviewing:
- Phone keys stay out of
search_vector— digit tokens would distortts_rank_cdand leak digit-soup into the palette'sts_headlinesnippets. - Arm g8 is a separate
union allarm, not anORin arm 7, so text search is byte-for-byte unchanged and phone hits carry their own score (1.0 exact / 0.5 fragment vsts_rank_cd's ~0.0x). Outerdistinct on (kind, id)dedupes rows matching both ways. - The backfill does not use the old
set data = datatrick:handle_user_table_updated_atstampsupdated_atunconditionally, and that drives both the grid's default sort andglobal_search's recency tiebreak. It honours a transaction-local GUC instead — which also makesreindex_user_table_searchlock-free.
Bundled in:
DataTableViewerWidget's two byte-identical search+filter blocks collapse into oneuseFilteredRowshook, now debounced 120 ms to match dial search (it previously re-filtered every row on every keystroke).ContactListWidgetandChildRecordsPickerdrop their ad-hoc matching in favour of the shared helper.- Phone columns are detected by name as well as type, so numbers
imported into text columns are searchable
(
phone/mobileas substrings;tel/cellword-bounded sohotel/excellentdon't match). - Five scattered copies of digit-stripping now share
phoneDigits(). - The SMS inbox sends PitchPrfct an E.164 term for phone
queries — their contacts are stored E.164, so typed formatting
matched nothing. This matches what
useDialSmsForLeadalready does successfully. - Master search now finds rows by phone in
clients,contacts,dependentsandcarriers— invisible before, sincesearch_textonly ever held display keys.
Deliberate behaviour changes:
- A formatted query under 4 digits (e.g.
(710)) no longer digit-normalizes; it can only match literally. - DataTable search is debounced 120 ms. The input echoes instantly; only the filter pass is throttled.
- Phone columns matched by name, widening which columns get digit matching.
Command palette: stop hiding every record behind a legacy workspace
Searching a phone number in the command palette returned
"No matches" for a row the table's own search box found
instantly. The phone normalization above was not at fault. The two
searches differ in one thing: the palette passed p_workspace_id
to global_search, while the table search filters already-loaded
rows in memory (makeRowMatcher), which has no workspace concept.
Root cause. Workspaces were removed from the product (org →
canvas), but older orgs still own a legacy workspaces row and
src/store/workspaces.jsx hydrates currentWorkspace to
restored || wsList[0] — so it's a real uuid, not null. The palette
handed that id to global_search, whose pages / user_tables /
user_table_rows arms filter workspace_id = p_workspace_id, while
everything those orgs own carries workspace_id IS NULL.
Measured on production before the fix — one org, same query, only the workspace argument differing:
| query | with workspace | without |
|---|---|---|
+1 (626) 488-7167 | 0 | 1 |
Karen | 9 (0 records) | 12 (3 records) |
So it was never phone-specific: all 12 tables, every row in them, and all 21 of that org's pages were invisible to the palette. A name query still returned calendar events and activities — neither arm is workspace-scoped — which is why only the phone lookup looked broken. A stale workspace filter fails closed, so it reads as a search bug rather than a scoping bug.
The fix — both halves.
-
Client.
GlobalCommandBarno longer takes or forwards aworkspaceId; the prop is dropped at theCanvasWorkspacecall site. Palette search is org-wide. -
SQL (migration 174). One named rule instead of five inlined copies of the predicate:
app_hidden.workspace_in_scope(p_row_workspace_id, p_filter) -- NULL filter = no filter -- NULL row workspace_id = org-scoped, visible under every filterAdopted by the pages, conversations,
user_tables,g7andg8arms. This is the durable half: it stops any future caller from silently blanking records the same way.
p_workspace_id is kept — dropping it changes the signature,
needing a drop/recreate plus a coordinated client deploy, for no
gain while workspaces rows still exist.
Clients: source column, backfilled from lineage / phone / email
clients had no lead-origin field, so once a prospect converted,
"where did this deal come from?" was unanswerable. Migration 175
adds a source select mirroring the prospects column and
attributes 101 of 116 clients.
Three rules, strict precedence, first tier that resolves wins:
| Tier | Rule | New matches |
|---|---|---|
| 1 | converted_from_row_id — exact lineage from convert_rows (056) | 86 |
| 2 | app_hidden.phone_search_key() equality | +13 |
| 3 | lowercased email | +2 |
Tiers 2–3 are validated, not assumed: on the 86 rows where lineage and phone/email both fired, they agreed on the source every single time. Zero disagreements, zero ambiguous rows.
Name matching is deliberately excluded. Measured against production it contributed exactly zero additional matches — every name match was already covered by phone or email — while being the one rule that can silently conflate two people sharing a common name. No gain, real risk.
Unmatched clients are left with no source key. Blank honestly
means unknown and stays filterable; a default would make
unknown data indistinguishable from attributed data.
Also repairs the stale prospects options list, which was missing
CABoom despite 37 rows already holding it.
Lead source: rename pitchPrfct → selfGen in place
648 rows (613 prospects + 35 clients). The row data was the easy part; migration 176 carries three other things with it:
- The options list on both tables — renamed in place, so
selfGenkeepspitchPrfct's slot and the dropdown order stays stable. - A saved view (already named
selfGen) filteringsource is pitchPrfct. This is the one that fails silently — rename the data without it and the view returns zero rows, no error anywhere. - 26
record_conversions.source_snapshotrows. Not just history:undo_conversionre-inserts them as live rows, so a stale snapshot resurrects a prospect holding a dead option.
What is deliberately NOT renamed. pitchprfct names two
unrelated things in this database and only one is a lead source:
| Left intact | Why |
|---|---|
integrations.provider='pitchprfct' ×2 | pitchprfct-api queries .eq("provider","pitchprfct") — renaming 404s the SMS proxy |
canvases.nodes_json "smsProvider":"pitchprfct" | SMS inbox widget binding |
email_events ×7 | genuine mail from the company |
record_activities.body ×1 | a human note about the product |
app_hidden.filter_tree_rename_value() walks saved filter trees at
arbitrary depth and rewrites only rule nodes bound to the target
field. A blunt replace(filters::text, ...) would have hit preset
names and rules on other fields.
Under the hood
- Migration 170 — scheduled email sends. Cron jobs + realtime
publication + idempotent re-declaration of the table, enum, RLS,
indexes and
updated_attrigger. Backfills a repo-only baseline for the MCP-applied schema. - Migration 171 — bucket folders.
dial_bucketstree columns (parent_id/is_folder/icon/sort_order), containment trigger, folder-shape CHECK guarded against NULL branches. - Migration 172 — landing overview RPC.
dial_buckets_overview(p_specs jsonb)returns every card's counts in one round trip. Reusesdialer_next_leads' CTEs soavailable_nowcan't promise leads the queue would refuse. - Migration 173 — phone canonical key.
search_phones text[]+ partial GIN, trigger extension,global_searcharm g8. Backfill runs under a transaction-local GUC soupdated_atisn't stamped. - Migration 174 —
app_hidden.workspace_in_scope. One named rule for workspace filter semantics; adopted by fiveglobal_searcharms.NULL row workspace_idtreated as org-scoped under every filter. - Migration 175 —
clients.source+ backfill. Three-tier attribution (lineage / phone / email) with an ambiguity guard; idempotent onsource is null. - Migration 176 —
pitchPrfct→selfGen. In-place value rename across data, options list, saved-view filter trees, andrecord_conversions.source_snapshot; self-contained (createsapp_hidden.filter_tree_rename_valuebefore use). DialerBodygained optional controlled props (bucketId/onSelectBucket/onExitToHome/onQueueExhausted/initialSearchRowId). Omitted, it behaves exactly as before — that seam keeps the legacy canvasdialWidgetworking, auto-select and all.useFilteredRows— one hook replaces two byte-identical search+filter blocks inDataTableViewerWidget, and gains a 120 ms debounce.lib/phone.js⇄app_hidden.phone_search_key— twin normalizers with a shared fixture file (lib/phoneSearch.fixtures.js) asserted in both runtimes.dialEmailCompose.jsstays the single build site for every dial-Email entry point'srecordContext(unchanged from 0.9.0, but relevant to how the new From row plumbs into it).
What's next
- Retire the workspace column. The client stopped writing it in
0.10.0's palette fix, but
CanvasWorkspacestill stampsworkspace_id: currentWorkspace?.idon newly created pages/canvases andapi.jsstill filters conversations by workspace id. Both are consistent today — the point is to stop writing a dead concept onto new rows before the trap resets. - Legacy dial-bucket flat filters.
dial_buckets.filters/dial_buckets.sortsare still read as the fallback when a client doesn't pass a view snapshot; safe to drop once the last pre-0.9 client is retired. - MMS on the dial / SMS composer. Copy picture (0.9.0) remains the bridge until PitchPrfct's send grows a media field.
See Dial, Inbox, and Quickstart → the command palette.
0.11.0 — MCP server ships (30 tools across the 6 native apps) + per-card loading skeletons on Dial home and Templates
The **MCP server** finally ships — an earlier draft existed in the tree but had never been deployed, had no UI to mint a token against, and covered only 4 of the 6 native apps thinly. This is the working suite — **thirty read-only tools across Tables, Calendar, Dial, Inbox, Pages, and Templates**, tokens that bind to `(org_id, user_id)` and query through the owner's own RLS (so isolation is a database property, not a per-tool discipline), a **Settings → MCP access** tab to mint and revoke, and it's live on production. Plus **per-card loading skeletons** on the Dial landing page and the Templates gallery, killing the "0 available now" false zero and the "No templates yet" flash on first paint.
0.9.0 — Dial queue pacing + freshness mix + email compose from the dialer + disposition rollup columns
The dial queue gets two opt-in gates — an **attempt-pacing ladder** that rests a lead after N calls (and can retire past a hard cap), and a **freshness mix** that interleaves aged leads into the working queue — plus an honest "N calls today" header count and a `12 calls` history chip on the contact panel. The dialer's Email tab picks up a **New Email** button that seeds the floating compose against the active lead (with templates that finally resolve `{{contact.*}}`, and a real Forward), and the Message button opens a **template menu** for SMS with an OS-handoff. Records get **most-recent-disposition rollup columns** — three scopes, all backed by one system-field registry — so buckets, filters, and the grid can address the same signal the eligibility engine already saw. Plus templates copy an SMS picture as a picture (and paste one in), calendar reminders stop firing per-tab and per-mirror-twin, and a signup-breaking `handle_new_user_org` regression is fixed.