Skip to content

Fix: silent chirp on every authenticated page load#234

Merged
bmoss2015 merged 373 commits into
mainfrom
fix/preconnect-silent-beep
Jul 2, 2026
Merged

Fix: silent chirp on every authenticated page load#234
bmoss2015 merged 373 commits into
mainfrom
fix/preconnect-silent-beep

Conversation

@bmoss2015

Copy link
Copy Markdown
Owner

Summary

Rik reported a beeping sound on every page while working real surplus leads (not using the dialer).

Root Cause

The layout-wide PreconnectOnMount component warms the Telnyx WebRTC session on every authenticated page mount. That transitions status from idleconnecting, which the audio-status effect interpreted as a real dial starting and played the "connecting" chirp.

Every page load = one chirp.

Fix

Introduced dialIntentRef. Only dial() sets it to true. preconnect() does not. The audio effect now checks dialIntentRef.current before playing chirp or ringback, so preconnect-only transitions stay silent. Flag clears when status returns to idle, ready, or hangup so the next real dial still triggers audio.

Test Plan

  • Load any authenticated page (Leads, /calls, /lead/[id], Settings, etc.). No sound.
  • Trigger a Call Back from a voicemail. Chirp plays, ringback loops during ringing.
  • Hang up. Load a different page. No sound.

🤖 Generated with Claude Code

bmoss2015 and others added 30 commits June 30, 2026 11:32
Fix SAVE-BAR-SAVED-FLASH: Confirm with checkmark for 1.6s

See merge request workflow-minds/next-surplus!9
Voicemail recorder V5:
- Native HTML5 audio rendered VISIBLY below the custom petrol player so playback always works regardless of what the custom button does
- Download Clip link so the operator can save the recording and verify it locally if the browser refuses to play
- catch() on a.play() with inline rejection reason surfaced as 'Custom play failed: <message>' so we stop swallowing browser errors silently

Voicemail drops table:
- 'Drops' renamed to 'Voicemail Drops' in both section headers
- Share / Unshare button replaced with Only Me / Available to Team visibility pill at fixed 150px width
- Separate columns for Preview / Visibility / Delete so trash icons line up vertically
- 'Your Voicemail Drops' and 'Team Voicemail Drops' empty-state copy updated to match the Available to Team terminology

Dialer:
- All 5 inline DispositionButton elements now fixed at w-[116px] so No Answer / Voicemail / Busy / Wrong Number / Failed align identically
- Keyboard shortcut keymap now flashes a 1.8s on-screen confirmation toast every time an action fires ('Voicemail. Advancing to next lead.', 'Microphone muted.', 'Session paused.', etc.) so the operator sees what just happened
- Muted state surfaces as a red 'Microphone Muted' pill pinned at top center of /dialer whenever mute is on
- 'Show this overlay' relabeled to 'Show All Shortcuts' in the overlay's Other section

Verification.html: Building Now bucket reduced to 0. Bundle marked complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… description, dialer two-row controls

Voicemail recorder V6:
- Killed the custom petrol play button + scrubber. Native HTML5 audio is the only player now. The custom button kept failing across 5 implementations with the most recent error 'no supported sources' (browser codec mismatch with the absolutely-positioned audio element). Native works.
- Removed the description field. Clip Name is the only metadata input now.
- Download link moved into the Preview header row, smaller and inline.
- Native player rendered full-width below the header row, controls visible. Single bar, no duplicates.

Voicemail drops table:
- 'Delete' column renamed to 'Remove' which is shorter and avoids the right-margin cut-off on narrow viewports
- Column widths rebalanced: Length 90 / Preview 100 / Visibility 180 / Remove 90 / Clip flex
- All padding reduced from px-7 to px-6 so the rightmost column has more breathing room before the card edge

Power Dialer call control row split into two rows:
- Top control row: Mute / Keypad / Hold / Transfer on the left, End Call (red, h-10) on the right with proper spacing
- Bottom row: 5 disposition buttons in a grid-cols-5 layout. Each button has w-full of its grid cell so all five are exactly the same width and the row never overflows. End Call no longer gets cut off because it lives on its own row.

Keyboard shortcuts: action toasts removed per anchored PhoneBurner pattern. Mid-call distractions are bad. The visual transition (call ends, wrap-up appears, queue advances) is the feedback. Mute pill stays at top because mute is a persistent state, not an event.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ments can load blob URLs

THIS WAS THE BUG ALL ALONG.

After 7 iterations on the voicemail-drop play button across both custom and native HTML5 audio players, the actual root cause was the Content Security Policy. The CSP declared default-src 'self' and never set a media-src directive. CSP spec says: when media-src is missing, fall back to default-src.

That meant every <audio> element with src=blob:... was blocked by CSP. The browser silently refused to load the source. No console error reached page state. Play button presses called .play() which returned a Promise that resolved without ever loading audio (or rejected with 'no supported sources' which is what Bree saw most recently). Native controls rendered but the bar showed grayed-out / unclickable because the audio was never in a loadable state.

The same CSP already explicitly allows blob: for img-src and worker-src. Audio blobs were just never added.

One line fix:
  'media-src 'self' blob:'

This unblocks both the native HTML5 audio player AND any future custom audio player against the same blob URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When SURPLUS (or any active Stripe promotion code) is applied at signup,
the 14-day trial was stacking with the 100% off coupon, producing a
confusing 'Free for 14 days' header above '$0.00/month until coupon expires'.

Signup form now exposes a 'Have A Promo Code?' field. When a code is
submitted, the server resolves it via stripe.promotionCodes.list, applies
it through subscription.discounts, and omits trial_period_days. Without a
code, the existing 14-day trial + allow_promotion_codes path is unchanged.

Same guard added to /api/stripe/create-checkout-session and the test-launch
action, which already pre-applied a promo but kept the trial.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…Hold/Transfer, wire Mute button, queue outcome chips on auto-disposition, 2s hero card before next contact
Stripe promotionCodes.list is case-sensitive. The live SURPLUS code is
stored as 'Surplus', so uppercase-only typing failed validation. Server
now tries the verbatim input plus uppercase, lowercase, and TitleCase
variants. Signup form no longer force-uppercases input.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… note, mute wired, keypad removed, transition card on manual disposition, HTML updated
…, no descriptions, trash wired, visibility left-aligned
Middleware was redirecting logged-in users from /signup -> / on every
request method. Server actions POST to the current pathname, so the
redirect blew up signup retries (and similar /login, /forgot retries)
for anyone with a stale session. Bounce now only applies to GETs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fix LOGO-LOCKUP: replace inline SVG with BrandLockup component

See merge request workflow-minds/next-surplus!10
…mute wired, outcome chips, hero transition, hold/transfer removed, delete wired, keyboard shortcuts
…footer/overlay, remove unused var, mark confirmed items shipped
…wn+recording cards petrol, justify-start on visibility, fresh verification with full URLs
- Drop allow_promotion_codes in Checkout: form field is the sole promo entry.
  Removes the duplicate Stripe coupon box that was producing the trial-stack
  display bug.
- Preview/dev: when createUser hits 'already exists', delete the orphan auth
  user + cascade profile/org and retry once. Lets the same test email be
  reused across iterations. Prod path unchanged.
- Diagnostic error in preview: invalid-promo message now reports which Stripe
  mode (live/test) the key is in, so live/sandbox mismatches surface fast.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… play/download/rename/visibility wired, transition wording, manual chip, audit+verification updated
Stripe Checkout hides the coupon name in tiny secondary text. Adds a
custom_text.submit message that explicitly states 'First Month Free with
Surplus. \$49/month after.' when the promo applies, or the standard trial
copy otherwise. Makes the deal unambiguous at checkout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…red ring active state, flip live-test badges to Shipped
…hases, mute clear active state, transition card wording, manual chip, HTML R13
If the auth user exists but their profile row is gone (e.g. preview
auto-cleanup wiped them between tests), the app layout's redirect to
/login looped: middleware bounced /login back to / because the cookie
still said 'logged in'. Sign out before the redirect so the cookie
clears and /login resolves normally.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Server components can't actually set cookies in Next 15, so calling
sb.auth.signOut() inside (app)/layout.tsx was a no-op. Move the clear
through /api/auth/clear-session (a route handler, which can write
cookies), then redirect to /login. Breaks the /signup -> / -> /login
-> / loop for stale-cookie users.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ng from Mute, remove top pill, dismissable footer with hover-restore + localStorage
…o-tracks latest build), add Round 14 test section, flip all stale recheck/in-progress badges to shipped
Loop root cause:
- /signup -> middleware bounces logged-in user to /
- / -> app layout sees onboarding_completed=false -> /onboarding/company
- /onboarding/company -> sees no subscription -> redirected back to /signup
- infinite

New /api/checkout/resume route recreates the Stripe Checkout session for
the current logged-in user and bounces straight to Stripe. Onboarding's
'no subscription' redirect now points there instead of /signup. Stripe
cancel_url drops them at /api/auth/clear-session so cookies clear cleanly
if they truly want to back out.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…KING-LOT: lead phone icons use CallButton (no more system dialer), QuickDial gets From picker + bigger input + no helper text, trash needs 2-click confirm, D7/D12/D13 added to roadmap parking lot
bmoss2015 and others added 26 commits July 2, 2026 11:56
…very logged call appears in /calls, not just transcribed ones
…tioning so browser autoplay policies don't block the remote audio track
…page mount + local WebAudio ringback during ringing

Bree confirmed audio works both ways after fixing her mic. Two remaining call-experience issues addressed:

1. Pre-warm the Telnyx WebRTC session on VoicemailInbox mount. Previously ensureConnected (mint creds + WSS handshake + Verto REGED) fired inside dial() on click, adding ~2s before Telnyx even began the outbound INVITE. Now the session is warmed up as soon as the /voicemail page loads via a new TelnyxCallContext.preconnect() method, so Call Back only pays the INVITE roundtrip.

2. Ringback tone during "ringing" state. Real dialers play the standard North American 440Hz+480Hz alternating on/off ringback so the operator has call-progress audio (matches what a landline sounds like when the far end is ringing). Implemented via WebAudio locally in the context provider: two OscillatorNodes, gain node, 2s-on / 4s-off ring pattern, stops automatically when status transitions away from ringing. No Telnyx-side flag needed (their connection-level generate_ringback_tone isn't exposed via API for this credential-connection type).
Client component mounted inside AppShell when access tier is not full.
Watches the DOM via MutationObserver for text patterns emitted by the
server-side guardWrite / assertCanWrite helpers:

- "billing_access_denied"
- "Read-only mode"
- "Billing on hold"

When any of those strings appear anywhere in the app (typically inside a
modal error slot after a Confirm click), the interceptor hides the raw
error message and opens a full-page overlay that explains the state and
routes to Settings > Billing. This closes the click-does-nothing UX gap
on read-only tier without requiring every modal to be individually
retrofitted.

The overlay is only mounted when access.tier !== "full", so paying
customers never see any of this machinery.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Feat: subscription access gate + comprehensive billing audit

See merge request workflow-minds/next-surplus!27
- Playwright fixtures use `use` as the callback name by library
  convention, which the react-hooks/rules-of-hooks rule mis-flags
  as a hook call. Exclude e2e/ (and Playwright artifact dirs) from
  ESLint entirely.
- AnnouncementBanner moved its sessionStorage read into
  useState's lazy initializer so the setState-in-effect rule
  passes on push. Same behavior, no cascading render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…io ringback, mark VM read on Call Back click

Three regressions from the last two builds addressed:

1. Panel disappearing on Hangup click: hangup() in TelnyxCallContext was setting activeCall to null synchronously, which made inPostCall false immediately (activeCall gate) and the FloatingCallPanel returned null before the disposition + note UI could render. Now hangup only clears activeCallRef (the SDK handle) and lets activeCall persist until the operator dismisses the panel post-save or a new dial replaces it.

2. Two-way audio regression after WebAudio ringback was added: the AudioContext created for the 440Hz+480Hz ringback was competing with the HTMLMediaElement <audio> tag on some Windows audio stacks for the output device, so once ringback played the remote WebRTC stream couldn't be heard. Removed WebAudio ringback entirely. Visual feedback in the floating panel is the fallback for call-progress indication.

3. Voicemail row still showing unread after callback: markVoicemailPlayed now fires the moment the operator clicks Call Back, not only when they click Play on the audio player. Clicking the callback IS the acknowledgement.
…CTUAL DB schema (audited every step against staging before push)

Silent bugs in the completion pipeline that made every save fail without error visibility to the operator. Verified each fix by running the exact insert/update sequence against the staging DB before pushing.

1. session_calls insert wrote to columns that do not exist (to_e164, from_e164, ended_at, placed_by). Real schema uses outbound_number_id (FK to phone_numbers) and dialed_at/answered_at only. Rewritten to look up outbound_number_id by e164 and drop the non-existent fields.

2. session_id was NOT NULL despite VM callbacks not being part of a Power Dialer session. Dropped the constraint via migration 0180. Every non-Power-Dialer call now inserts cleanly.

3. Contact phone lookup could not match "803-206-0345" against "+18032060345" because filter used naive .in() with three format variants and none included the dash format. Now: fetch all phone contacts, normalize both sides to trailing-10-digits, compare. Same fix applied to the wrong_number auto-action's contact-flag path.

4. tasks insert wrote to due_at + assigned_to + created_by — none exist. Real columns are due_date (date) + due_time (time) + user_id. Rewritten.

5. notifications insert wrote to user_id + kind + subject + body + href. Real columns are recipient_id + type + lead_id + body_preview. Rewritten to the actual schema.

Backend verification done: every insert and update in the pipeline was executed against staging with the values the server action would send. All succeeded. Test rows cleaned up. Now the server action code emits exactly the SQL that was proven to work.
Resolved BillingSection.tsx: kept new plan-hero-line stateLine and
Manage Subscription button styling from main + billing testids from
this branch (billing-plan-name, manage-billing-button).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add Playwright E2E suite (23/23 green) + fix time_zone typo + non-fatal invite email

See merge request workflow-minds/next-surplus!28
Addresses the actual issues Bree flagged, plus edge cases on multi-lead phone matching.

Activity display
- activity-format.ts now has a proper case for call_completed so the lead timeline renders one clean line: "Outbound Call (2:35) · Spoke — <note>" instead of the fallback "Call Completed" + separate disposition text that looked like two rows.

Notes on lead's Notes tab (Discussion)
- Notes tab on lead detail reads from discussion_comments, not activities.payload.note. Now: when a call is saved WITH a note AND a linked lead, the note is also appended as a discussion_comment (author = operator, body prefixed with disposition context). Bree's Notes tab will show it.

Multi-match ambiguity
- resolveLeadByPhone now returns {leadId, ambiguous, matchCount}. If 2+ leads share the same phone (Marcus vs Robert case that just fired), we DO NOT auto-link — silent wrong-linking never happens. Operator must link manually via existing Link To Lead flow.
- Return type of logQuickCallOutcome extended with lead_id + ambiguous_matches so the panel can react.

Unlinked-call notes
- Floating panel post-call section now says explicitly: "Not linked to a lead. Note saves to Call history only. Link to a lead to also add it to the lead's Notes tab." Explicit UI contract so the operator knows where the note goes.

Explicit audio-stream bind
- Hypothesis: the remoteElement constructor option isn't reliably binding the destination MediaStream on all SDK versions / after some browser state changes. Fix: when the SDK notification transitions the call to 'active', grab call.remoteStream from the notification's call object and set srcObject on #telnyx-remote-audio ourselves, then call .play(). Errors are logged to console so we can diagnose (autoplay block vs missing stream vs SDK version issue) if it still fails.

Not addressed in this commit (deferred, will handle next round):
- Dead air during "calling" and "ringing" states — needs a static MP3 fallback since WebAudio ringback regressed the WebRTC output. ~20 min once approved.
- 12-second dial latency — Telnyx-to-destination-carrier routing time is not fixable in code. Only fix is server-originated two-leg dialing which is an architecture change, not shipped.
…o it doesn't block WebRTC audio

Prior WebAudio ringback regressed two-way audio on Windows because AudioContext.close() is async and WASAPI would hold the audio session past when the WebRTC HTMLMediaElement tried to start playing the remote stream. This attempt:

- Explicitly disconnect all AudioNodes before closing the context
- Call suspend() before close() to release the device synchronously
- Trigger on both "calling" AND "ringing" states so operator hears audio from click to answer, not just after INVITE routes (was silence-then-tone)
- Stop cleanup happens the moment status transitions to "active" (or any non-ringback state), before WebRTC binds its stream
Ringback audio was silent because AudioContext started in "suspended" state per browser autoplay policy and I never called resume() — dead air was literally that. Added void ctx.resume() right after context creation. Also switched from setValueAtTime scheduling (which was inconsistent) to direct gain.gain.value writes on setTimeout ticks with asymmetric 2s-on/4s-off pattern.

Activity feed duplication (three rows for one call): the /leads/[id] activity feed merges activities + discussion_comments + session_calls tables. Every call was showing up three times because I was inserting into all three. Removed my activities.call_completed insert AND the discussion_comments insert — the session_calls row surfaces as a single "call" activity automatically with disposition + note already in its payload. Now: one call = one row on the timeline. Note truncation on the activity display already caps at 80 chars via the existing "call" activity formatter.

Trade-off called out: since notes no longer duplicate into discussion_comments, they won't show under the lead's Notes tab. Notes remain visible via the call activity row on the timeline and on the /calls page. Wiring session_calls.note into the Notes tab source is a follow-up if you want notes surfaced in two places.
Three fixes shipped:

Ringback (dead air):
- Ditched the WebAudio synth entirely — every variant fought browser autoplay/WASAPI quirks and stayed silent for Bree. Now: generate an 8kHz mono 8-bit WAV blob (2s of 440Hz+480Hz mixed sine + 4s silence) at mount time, wrap in a URL.createObjectURL, play via plain HTMLAudioElement with loop=true. Same HTMLMediaElement pipeline as the WebRTC remote-audio stream so no output-device conflict. Play triggered on status transitions to calling/ringing; paused on any other state including active + hangup.

Activity display:
- Call activity row now reads "Outbound Call · <Proper Case Disposition> · <duration> · '<80-char note snippet>...'" instead of the raw "Call · wrong_number · 2:35" that leaked snake_case + didn't include the note.
- Note snippet capped at 80 chars with ellipsis so long paragraphs don't blow up the timeline row.

Note dedup + Notes tab:
- Restored the discussion_comments insert so notes surface on the lead's Notes tab (Bree's follow-up complaint: current test made no note).
- Comment body is prefixed with a "[call-note] " marker internally.
- Activity feed merge filters those out so they don't show as a duplicate "note" row alongside the call row.
- listDiscussionComments strips the marker on display, presenting the note as "Call Note (Wrong Number): user's text" on the Notes tab.

Result: one clean activity row per call on the timeline, note visible on Notes tab, no duplication.
Prior build played a synthetic ringback the moment Call Back was clicked, misleading the operator into thinking the destination phone was ringing when Telnyx was still routing the INVITE. Now:

- On click (status = calling): plays a short two-beep "connecting" chirp ONCE (~700ms total, soft 400Hz, feels like a landline pickup acknowledgement). Then silence with visual "Connecting…" status. No lying about the destination phone.
- When Telnyx confirms the destination is actually ringing (status = ringing, from SIP 180 Ringing): the real US-standard 440Hz+480Hz ringback loop starts. This is the same tone the destination phone user would hear on their end.
- On status = active or hangup: both audio elements pause.

Matches Aircall/Dialpad/RingCentral behavior — operator hears ringback only when the phone is actually ringing, gets a click-acknowledgement chirp so the click isn't silent. Same HTMLAudioElement pipeline as before, no WebAudio.
…he floating panel during the calling state so it matches the actual routing phase (not fake ringing)
Two small UX bugs from Bree's test:

1. USER_BUSY (17) shown when operator hangs up during Connecting/Ringing. The Telnyx SDK defaults cause="USER_BUSY" for any pre-Active hangup, misleading — operator wasn't busy, they cancelled. Added operatorHangupRef flag flipped true in the hangup() callback and consulted in the notification handler to suppress the misleading error banner. Panel just shows "Call ended" cleanly.

2. Static "Connecting…" / "Ringing…" labels felt frozen during the 7s routing delay. Added animated three-dot ellipsis (dotBounce keyframe, 1.2s cycle, 150ms stagger between dots) after the label text so the user gets clear visual feedback that the system is actively working. Matches iOS Messages / Slack typing-indicator pattern.
…notifications (Telnyx fires hangup+destroy+purge in sequence and the flag was being consumed by the first)
…mailInbox to (app) layout so any authenticated page mount warms the session
…ate (WSS handshake), not just 'calling'. Cold-session dials now show Connecting… + play chirp within ~50ms of the click instead of after the 2s handshake.
…g stack, call completion pipeline (session_calls + activity + notes + auto-actions + notifications), ringback tone with connecting chirp, USER_BUSY suppression on operator hangup, layout-level pre-warm, connecting-state instant panel feedback
…ng state opens panel + fires chirp within 50ms of click instead of after 2s WSS handshake
…Connected which set status to connecting, tripping the audio effect designed for real dials. Gate the chirp + ringback on a new dialIntentRef flag that only dial() sets.

Rik reported a beeping sound on every authenticated page while working real surplus leads without opening the dialer. Root cause: layout-wide PreconnectOnMount warms the Telnyx session on every page mount, which transitions status idle → connecting. The audio-status effect saw that transition and played the "connecting" chirp, even though there was no real dial in progress.

Fix: introduce dialIntentRef, set true inside dial() before ensureConnected, cleared when status returns to idle/ready/hangup. The chirp + ringback effects now check dialIntentRef and stay silent for preconnect-only transitions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@supabase

supabase Bot commented Jul 2, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project rsdmyydyhqgkkvwlklif due to reaching the limit of concurrent preview branches.
Go to Project Integrations Settings ↗︎ if you wish to update this limit.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

…state-in-effect at VoicemailInbox:377). The reset-on-empty-query is intentional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bmoss2015 bmoss2015 merged commit a0571a9 into main Jul 2, 2026
3 of 4 checks passed
@bmoss2015 bmoss2015 deleted the fix/preconnect-silent-beep branch July 2, 2026 20:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant