Bug 1: .ptr-ind used top:8px so the pull-to-refresh spinner sat under the notch/Dynamic
Island. Now top:calc(var(--sat)+8px) clears the safe-area inset.
Bug 2: video attachments rendered as a plain download link that re-downloaded on every
tap. Server now sends isVideo/isAudio on message attachments; videos render as an in-chat
player — masked poster with a DOWNLOAD button that loads the file ONCE (preload=none ->
load on tap), then becomes a PLAY button; playing hands off to native inline controls, so
no repeat downloads. build batch155.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The conversation pane now tracks your finger from the left edge and reveals the chat
list behind it, completing the back past ~35% width or springing back otherwise. Reuses
the existing body.chat-dragging layout (already defined, identical to chat-closing that
showWelcome uses): content z-index:2 at translateX(0), list .chatcol absolute behind at
z-index:0 — so the pane starts at the correct on-screen origin (the earlier attempt's
'one screen-width off' bug was a different setup). Vertical drags still scroll; popup/
search edge-release still closes via bzcBack. build batch154.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Investigations are closed, so strip the probes: bzDbg + __perfProbe definitions, the
older/render/slide/pwaFocus call sites, and the server-side /api/dbg (MDBG) sink. Kept
the functional code around each probe (renderThread's innerHTML build, the older-page
re-anchor, the slide fade). No behavior change. build batch153.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of 'tapping stops the Bluetooth audio': bzUnlockAudio fires on EVERY tap
during a call and called bzApplyRoute -> setSpeaker(false) -> configureDevice, whose
setCategory+setActive(true) reconfigures the AVAudioSession mid-call and interrupts
WebKit's audio unit, dropping the call audio. Fix: bzApplyRoute on iOS no longer touches
the session at all (only wires the route-icon listener); iOS keeps auto-routing. build batch152.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Telemetry confirmed the ceiling: a single speaker override holds ~1s over active BT then
WebKit reverts. So stop trying to control the route on iOS — always use the default port
(iOS auto-routes: BT/wired if connected, else loudspeaker), and make the button a live
INDICATOR of the real output; tapping shows a toast (connect/disconnect a headset to
change). Removes the temp nroute/sptap probes. Pure web change; plugin v1.1.2 already
supports it. build batch151.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v1.1.1's route observer re-forced speaker on every change, and WebKit re-added Bluetooth
each time -> the audio flapped speaker<->BT many times/sec (telemetry: dozens of route
flips from 3 taps), which read as 'sound doesn't switch'. WKWebView won't let the app
hold the built-in speaker over an active BT device. So: one override per tap, observer
only REPORTS the output (no fighting). Also stop dimming the iOS button (it's a live
output indicator, not on/off; the dim read as 'disabled' on BT). Probe now carries the
native marker so we can confirm the binary. plugin v1.1.2-stable, web batch150.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
overrideOutputAudioPort(.speaker) alone can't beat a connected BT headset (BT is higher
priority), so 'Speaker' snapped back to BT. Now setSpeaker(true) sets category options
[.defaultToSpeaker] (no allowBluetooth) so BT isn't an eligible output and the speaker
wins; setSpeaker(false) restores [.allowBluetooth,.allowBluetoothA2DP] and uses the
default port (routes to the headset). Observer re-holds speaker if a BT connect steals it.
Adds a TEMP web probe (nroute/sptap) to verify from telemetry. plugin v1.1.1, web batch149.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BT audio already routes on iOS, but the web UI can't see it (iOS hides audio outputs
from enumerateDevices), so the icon was stuck on speaker. Plugin v1.1.0 now exposes the
active output: getRoute() + a 'routeChange' event ('speaker'|'bluetooth'|'wired'|
'receiver'|'airplay'). Web subscribes and drives the icon/label from the real route
(bluetooth/headphones/speaker), and the iOS toggle becomes a 2-state Speaker <-> Device
cycle (JS can't enumerate outputs there). Also strips the earpiece-investigation debug
logging from the plugin. Native needs one Codemagic build; web is live (batch148).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Proven via device telemetry: inside a WKWebView, WebKit owns the WebRTC audio unit and
forces the loudspeaker; overrideOutputAudioPort(.none) is a no-op (route settles on
Speaker 1.2s later), so the built-in earpiece cannot be selected. Present only what
actually works on iOS: Speaker, and Bluetooth/wired headset when connected. bzApplyRoute
now maps only 'speaker' to the loudspeaker override; bt/headset use the default port.
Coerce any stale 'earpiece' pref to speaker on iOS. Also strips the route debug telemetry.
Other platforms keep the earpiece option. build batch147.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1.0.3 telemetry showed mode already == .voiceChat when earpiece still landed on
Speaker, and setSpeaker read the route synchronously (stale) right after the override.
So the mode re-pin alone may be insufficient and the sync read is unreliable.
1.0.5: keeps the .voiceChat re-pin, but stamps live mode into every route-change log
line and re-reads the SETTLED port+mode at +0.4s and +1.2s after each toggle (reported
in the next toggle's trail). This definitively answers whether WebKit flips to .videoChat
and where the route truly settles. native marker 1.0.5-settle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause (WebKit source MediaSessionManagerCocoa.mm + Apple DTS, confirmed by
telemetry): WKWebView's WebRTC re-pins the session to mode .videoChat while capture
is active, and .videoChat auto-implies .defaultToSpeaker. So override(.none) reverts
to the mode default = LOUDSPEAKER, and .none alone can never reach the earpiece once
WebKit flips the mode. Our first override won only because .voiceChat was still active.
Fix: for earpiece, setMode(.voiceChat) (its default route IS the receiver) before
override(.none) in both setSpeaker and the debounced route-change re-assert. Add an
accessory guard so a connected BT/wired headset isn't yanked to the built-in receiver.
Reconcile the launch patch: drop .defaultToSpeaker from inject-audio.js so it stops
contradicting the plugin. native marker 1.0.4-mode.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Telemetry proof: overrideOutputAudioPort(.none) routes to Receiver once, then every
later toggle latches to built-in Speaker despite opts=36 (no .defaultToSpeaker) —
WebKit's WebRTC engine re-forces the loudspeaker after our override. The prior build
ignored .override-reason route changes and never corrected it.
Now: react to ALL route changes, debounced 0.25s, and re-assert the chosen port only
on a genuine mismatch (self-terminating, capped at 6/toggle to avoid thrash). setSpeaker
returns a reason->port route-change trail so the log shows whether WebKit is one-shot
or persistent. native marker 1.0.3-reassert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Telemetry only showed 'override didn't throw', and the web __BUILD tag can't tell
native binaries apart, so we couldn't see WHERE iOS actually routed the audio or
which plugin build ran. setSpeaker now resolves with the real currentRoute output
port (Receiver/Speaker/Bluetooth), the live AVAudioSession category/mode/options,
and a native-build marker (1.0.2-diag) so the route log is unambiguous.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Speaker worked but the earpiece was silent, and call audio flickered on speaker
then dropped. Causes: (1) the category set .defaultToSpeaker, so overrideOutputAudioPort(.none)
fell back to the loudspeaker instead of the receiver; (2) setSpeaker re-ran
setCategory+setActive on every toggle mid-call, tearing down the audio unit WebKit's
WebRTC engine was using and silencing the earpiece route.
Fix: drop .defaultToSpeaker (drive the port explicitly), make setSpeaker flip ONLY
overrideOutputAudioPort, and observe routeChangeNotification to re-assert the chosen
route when WebKit reconfigures the session at call start / device change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Capacitor derives the pod name from the npm package name (audio-route -> AudioRoute)
and writes 'pod AudioRoute, :path => ../../plugins/audio-route' into the generated
Podfile. CocoaPods then requires a file literally named AudioRoute.podspec whose
s.name is 'AudioRoute'. The old AudioRoutePlugin.podspec (s.name AudioRoutePlugin)
caused the Codemagic build to fail with 'No podspec found for AudioRoute'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device telemetry proved the app-embedded AudioRoute class never registered (not in
Capacitor.Plugins) — appending a CAPBridgedPlugin to AppDelegate.swift gets stripped/undiscovered
in release builds. The plugins that DO register (Share, Camera, Filesystem) are all npm packages
wired by cap sync. So AudioRoute is now a local plugin package (mobile/plugins/audio-route,
file: dep in mobile/package.json) with a podspec + CAPBridgedPlugin Swift — cap sync adds its pod
and Capacitor registers it like the others. load() sets the launch speaker default; setSpeaker({on})
overrides the output port. inject-audio.js no longer injects the plugin class (would duplicate);
it keeps only the AppDelegate launch default as a fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Server streams the upload body straight to /data/uploads (a .part temp file, atomic rename on
success), backpressure-aware, so a 1 GB file never buffers in RAM. MAX_UPLOAD_MB env (default
1024 = 1 GB) controls the cap; error message reflects it.
- Client size guard raised 25 MB -> 1 GB.
- docker-compose documents MAX_UPLOAD_MB and the required Nginx Proxy Manager client_max_body_size.
NOTE: the actual bottleneck for the user's 9.7 MB reject is almost certainly NPM's client_max_body_size
(nginx default 1 MB) — that must be raised in the NPM admin; the app change alone can't lift it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ios-patch.sh now injects an AudioRoutePlugin (CAPBridgedPlugin, Capacitor 7 auto-registers it)
into AppDelegate.swift with setSpeaker({on}) -> AVAudioSession.overrideOutputAudioPort. Tolerant/
build-safe: if it doesn't register, the web call just no-ops (can't crash or fail the build).
- web: nativeAudioRoute()/bzApplyRoute() drive the plugin; toggleSpeakerphone + the on-join/on-tap
unlock now actually switch the route on iOS (setSinkId can't). canRouteAudio() shows the toggle
when the native plugin is present. Dormant until the next Codemagic build ships the plugin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drag the open conversation rightward from the left edge and it follows the finger 1:1 with the
list parallaxing in underneath; release past ~35% (or a quick flick) completes the pop, else it
snaps back. Fixes the old stuck-pane bug: once a clear horizontal drag is detected we
preventDefault (passive:false) to CLAIM the gesture so iOS/scroll can't steal it and fire
touchcancel; touchcancel always resolves to a clean state. showWelcome(skipAnim) does the state
swap without re-animating. Works in native app, PWA and mobile browser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Calls were routing to the quiet earpiece. ios-patch.sh now runs a Node helper (Node 20 is
already in the build env) that injects an AVAudioSession .playAndRecord/.voiceChat category with
.defaultToSpeaker + Bluetooth into the generated AppDelegate. Tolerant: exits 0 and no-ops if the
template differs, so it can never fail the Codemagic build. Verified locally against the Cap 7
AppDelegate template — injects correctly and is idempotent. First pass; if WebRTC re-grabs the
session mid-call on device, a follow-up plugin will re-assert .overrideOutputAudioPort(.speaker).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Suppressing the save dialog made downloads completely silent — "nothing happened" even though
the file saved to Downloads. Now on download 'done': (1) tell the web UI → branded toast
"Saved X to your Downloads folder" when the window is focused; (2) native OS notification
(click → reveal in Explorer) when the app is minimized/in the tray, so it's never double-noticed;
(3) a failure notice. preload exposes onDownloadDone; home.html shows the toast.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The app closes to tray, so it kept running on the old version and only checked on a fresh launch.
Now: (1) check for updates on EVERY window show (X-to-tray then reopen, taskbar, relaunch via
second-instance) via win.on('show'), throttled 1/10min; (2) explicit autoInstallOnAppQuit=true so
a downloaded update installs on the next real quit / PC restart even if the user never clicks
"Restart now"; (3) native notification when an update finishes downloading while hidden in the
tray. The existing 6-hour background timer is unchanged and still runs regardless of window state.
Also carries the 0.1.18 downloads-to-Downloads-folder change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Accessory bar: call Keyboard.setAccessoryBarVisible({isVisible:false}) on iOS in the native
IIFE, hiding the grey chevrons+Done strip above the keyboard. The plugin is already bundled
in the current TestFlight build, so this takes effect on a web deploy — no rebuild.
- Attach: tapping the paperclip now opens a composer-anchored Photos/Camera/Document menu
(like the emoji/mention popups) instead of firing the generic mid-screen OS chooser as the
first thing. Each option opens a type-scoped picker (image/*, capture). Web fix, all clients.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Mobile: the Return key now inserts a newline instead of sending (send via the button, like
every native chat app). Desktop keeps Enter=send / Shift+Enter=newline.
- Image preview: hide the on-screen +/- zoom buttons on mobile (pinch-to-zoom covers it).
- Desktop (Electron): a will-download handler saves straight to the OS Downloads folder with no
"where to save?" dialog, de-duping the name if it exists. NOTE: desktop code only — NOT
published to the update feed (needs an explicit desktop rebuild/publish).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mobile viewport fixes only ever went into home.html. The other standalone pages still had
the old viewport, so in the native app they auto-zoom on input focus and (dashboard/host) run
under the notch/Dynamic Island. Bring every page's viewport to match home.html
(maximum-scale=1, user-scalable=no, viewport-fit=cover) and add safe-area top padding to the
dashboard header and the host body/indicator so nothing sits under the island now that the
viewport is cover. index/connect/share already pad for safe-area; they only needed maximum-scale.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
iOS home-screen PWAs IGNORE viewport maximum-scale (Apple disabled it for accessibility), so
the maximum-scale=1 that stops auto-zoom in the Capacitor WebView does nothing in the PWA — it
still zooms on input focus. Real cross-platform fix: make every focusable text field 16px on
mobile (the composer was .92rem). autoGrow's empty-guard keeps it one line.
Also the PWA has no Capacitor Keyboard plugin, so the composer never lifted above the keyboard
("not the same keyboard"). Added a VisualViewport-based lift for non-native clients (browser +
PWA); native still uses the plugin. >100px threshold ignores the Safari toolbar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug: for ~3.4s after opening a chat, the "keep newest in view as images load" pin schedule
would yank you back to the bottom if you tried to scroll up for older history — so scrolling
up snapped back to latest, and only worked once the schedule expired (~5s). Fix: the user's
first scroll gesture now sets _openScrolled, which cancels the pending pin timers and short-
circuits _pinNewest (so the late image-load pins stop too). loadOlder/anchor then works
immediately, no yank.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device log showed the slide motion itself is now clean 60fps (frames 0-15 all ~16ms in every
sample); the only remaining spike was at frame ~16 — AFTER the slide lands — from applying the
network thread result + pin at that instant (the "adjusts after it lands" feel). Since the open
is a compositor opacity/transform fade, main-thread work during it doesn't stall the animation
and is masked by the low opacity. So apply the reconcile/render + pins immediately when the
fetch returns (mid-fade) instead of deferring to slide-end. By the time the pane is fully opaque
the content is already settled. Removed the now-obsolete appendBubble mid-slide deferral.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The reflow that caused the end-of-slide hitch was never the content slide itself — it was
SHOWING the list column during the slide and hiding it (display:flex->none) at the end. So
keep .chatcol display:none the whole time (untouched) and animate ONLY .content: it pushes in
from translateX(12%)->0 with an opacity fade, on its already-promoted GPU layer. Real slide
motion, and nothing to reflow when it ends. (This also explains the batch126 breakage: that
made .chatcol position:absolute, disturbing the touch/layout target — not touched here.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User chose the fade over chasing the WebView slide-teardown reflow. Open now: the cache-
rendered conversation is placed instantly and fades opacity 0->1 over 140ms. No transform,
no off-screen pane, no list-column display toggle -> nothing to reflow at the end, so the
~40ms end-of-slide settle cannot occur. Same afterOpenSlide queue flushes network reconcile/
pins/appends at fade-end. Desktop unaffected (fade is mobile-only via __freshOpen).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The list-column position:absolute change stopped conversations from opening. Revert to the
known-good display:none. Keep the other batch124-125 fixes (content promoted, reconcile,
reopen-at-latest, download interceptor). Slide back to the batch125 behaviour (opens fine,
minor end hitch) while I find a safe way to remove the reflow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device log proved the residual 30-78ms end-of-slide hitch is NOT the content-layer demote
(content stays promoted) and NOT the re-render (reconcile, longtasks:[]). It's the chat-list
column flipping display:flex->none at transitionend, which reflows that whole subtree. Keep
the list RENDERED behind the conversation (position:absolute; z-index:0; covered by the opaque
content pane) instead of toggling display. Teardown now only clears transforms = no reflow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slide: the residual ~40ms end-of-slide hitch (down from 128ms in b124) was the layer
DEMOTE + the deferred full renderThread at transitionend. Two fixes:
- Keep .content promoted for the WHOLE time a chat is open (will-change lives on
body.chat-open, not on the animating class). The open transition now only changes the
transform VALUE 100%->0; when it ends there is nothing to tear down -> no demote raster.
- Slide-end no longer rebuilds all 40 nodes: reconcileOpen() appends only the 0-2 genuinely
new tail messages (full renderThread only if the page structure diverged).
Download: routing to Safari failed auth (no login cookie). Real fix: fetch the file in the
WebView (cookie present) and hand the bytes to the OS save/share sheet via Filesystem+Share
(added to mobile deps; ships next TestFlight build). Until then, images fall back to the iOS
long-press "Save to Photos" instead of breaking the app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slide: the 40-128ms hitch was a compositor RE-RASTER at transitionend, not JS
(longtasks:[], renderMs 2-6ms in the device log). Removed contain:layout paint /
isolation:isolate from the .content slide layer and the filter:brightness parallax
on .chatcol (both force a full re-raster on teardown); defer the layer demotion two
frames past transitionend so it lands off the motion path. Also replaced the fragile
length+lastId slide-end skip (it almost never held once a live message grew the cache,
so the full innerHTML rebuild kept running at slide-end) with an exact per-message diff.
Reopen-at-latest: loadOlder wrote the GROWN thread (100+ msgs) into THREAD_CACHE, so
re-opening re-rendered all of it and stranded you mid-history as images shifted the
(pre-load) bottom past the 1200px pin guard. Open now renders the latest PAGE only;
added _forcePinOpen to glue to the newest through late image loads until the user scrolls.
Download hang: <a download> navigated the whole WKWebView away to the raw file (no back,
app frozen). Native-only capture-phase interceptor opens downloads in the system browser.
Lightbox close/download buttons: solid dark chips so they're visible over bright images.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device log: slide is 60fps EXCEPT one ~60-130ms hitch at the END = the deferred renderThread rebuilding
the (identical) DOM at slide end. Skip it when the network result matches the cache render (signature).
Load-older: fired at scrollTop<120 = during the rubber-band over-scroll past the top (beforeScrollTop
was -289), so the prepend+anchor jerked momentum. Trigger earlier (<700, skip negative scrollTop) so it
loads while still scrolling and never interrupts the bounce.
Build marker -> 2026-07-19-batch123.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The perf-diagnostics script tag was never closed, so it merged with the following native/keyboard
<script> and threw 'Unexpected token <', disabling the keyboard/safe-area init. Add the missing </script>.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-add /api/dbg + a frame-gap/long-task probe over the chat-open slide, the innerHTML render time, and
before/after scroll for load-older — to measure on-device where the jank actually is (main-thread block
vs compositor) instead of guessing. Temporary; removed once fixed.
Build marker -> 2026-07-19-batch122.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slide (workflow-verified): the jump was the main thread building the message DOM + scroll writes DURING
the 300ms animation. Now: render off-screen first, then arm a transform-only slide on a promoted GPU
layer (translate3d/contain:layout paint) with all innerHTML/scrollTop work deferred to transitionend
(afterOpenSlide queue). armOpenSlide runs after the sync render; appendBubble defers live writes mid-slide.
Load-older jump: anchor on the specific oldest-loaded message element (not scrollHeight math, which broke
when prepended images loaded and shifted content) and re-anchor as those images load.
Removed all /api/dbg diagnostics (client reporter + server route + spike/diag probes).
Build marker -> 2026-07-18-batch121.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User's insight: the jank/jumps come from rendering ALL ~500 messages on open. Load only the latest 40
(server + client PAGE), and the existing loadOlder() pages in older history on scroll-up with a scroll
anchor (no jump). Whole-thread SEARCH is a separate endpoint, unaffected. Also #1: keyboard-show only
pins to newest when already near the bottom, so replying to an OLD message no longer yanks to latest.
Build marker -> 2026-07-18-batch120.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Pager: force the staged (opposite-edge) frame to PAINT (void offsetWidth) before animating in, so the
incoming image enters from the correct side (was appearing from the exit side). Hide prev-arrow at the
first image and next-arrow at the last.
- #1: keyboard closed after reply-cancel but the --kb lift stayed (empty gap). Add keyboardDidHide reset +
a composer-blur fallback that drops --kb when focus leaves the composer.
Build marker -> 2026-07-18-batch119.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Reply quote: was showing the FULL quoted body untruncated -> DIV.quote rendered 1571px, ballooning the
bubble + leaving the chat not-at-bottom. Clamp to 2 lines (-webkit-line-clamp) + collapse newlines.
- Swipe-to-reply: translateX(+72px) on a right-edge bubble pushed past the viewport -> a horizontal scroll
bar. .convo-msgs overflow-x:clip stops it.
- Image pager (#8): replace instant src swap with a real slide — current image slides out, next slides in
from the opposite side; clamp at first/last (no wrap -> no blank slide past the end).
Build marker -> 2026-07-18-batch118.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Parallel root-cause analysis: during body.chat-opening the pane is momentarily ~0 width; word-break:
break-word / overflow-wrap:anywhere let bubbles collapse to 1-GLYPH min-content, so pre-wrap text wraps
into hundreds of lines (5684px 'balloon'); same inflates the empty/draft textarea (autoGrow->140=4 lines);
and translateX(100%) of a ~0-width pane = no travel = the 'jump'. The tall box hid in anonymous line
boxes (renderMsgBody emits bare text nodes), which is why the probe only saw the 84px timestamp span.
Fixes (all @media max-width:760px / chat-opening scoped, desktop untouched):
1. .bubble: word-break:normal + overflow-wrap:break-word (min-content = longest word, never collapses)
2. mobile bubble group: overflow-wrap:anywhere -> break-word
3. .msg-link: word-break:break-word -> overflow-wrap:break-word (URL-only msgs can't reopen the hole)
4. body.chat-opening .content: width:100vw -> definite width, no collapse frame, real slide travel
Build marker -> 2026-07-18-batch117.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tallImg was no-img, so the balloon is a TEXT bubble stretching (even in a 17-msg chat). Add: composer/
textarea height+scrollHeight+rows+value+fontSize+lineHeight (why ~4 lines), whether chat-opening class +
.content animationName are applied (why transition jumps), and the tall bubble's text + tallest child.
Build marker -> 2026-07-18-batch116.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Spike log showed bubbles ballooning to 3576-5684px (an image loading at natural ~2778px height before
constraint). att-img is capped 240 but some image path wasn't — added .convo-msgs img/.bubble img
max-height:340px as a blanket cap so no message can stretch the thread. Enhanced spike report to name
the tall bubble's img (natural size, rendered height, computed max-height) to confirm.
Build marker -> 2026-07-18-batch115.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Revert font-size:16px !important (it made the composer ~4 lines tall). Disable iOS zoom with
viewport maximum-scale=1,user-scalable=no instead -> composer keeps its size, AND double-tap-to-zoom
(in-chat search arrows #3) is gone. touch-action:manipulation on controls as belt-and-suspenders.
Custom lightbox pinch still works (it's transform-based, not browser zoom).
- Transition: show the newest message immediately (was hidden until images loaded -> empty pane slid in
then popped = the 'jumping' feel). Pinning still prevents dance.
Build marker -> 2026-07-18-batch114.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Transition: open now slides the whole .content pane in over the list with a subtle parallax + iOS
easing (was an instant/panel-only slide). Close/back already parallax.
- scrollH blip: added a spike watcher that reports the tallest element (class + img src tail) when the
thread's per-message height balloons, so we can see what stretches then settles.
Build marker -> 2026-07-18-batch113.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device log (post-zoom-fix): keyboard open leaves clientHeight AND visualViewport at 926 -> vv gives 0 ->
composer covered. On resize:none the plugin's keyboardHeight is the only signal; now that zoom is gone
it's clean, so --kb = keyboardHeight lifts the composer flush, and keyboardWillShow at animation start +
the CSS bottom-transition makes it slide up WITH the keyboard (smooth). Config pinned to resize:none.
Build marker -> 2026-07-18-batch112.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Debug now reports curKb/compBottom/kbTop/coveredBy on keyboard events so I can see WHY the composer is
covered (lift coming out 0) from the server log.
- Transition: force the bzConvoIn slide to replay on every chat open (CSS animation only auto-plays on a
class change, so chat->chat looked instant). Restart via reflow + inline animation.
Build marker -> 2026-07-18-batch111.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device log confirmed zoom persisted (vvW:394,vvTop:74) because .composer-row textarea{font-size:.92rem}
out-specified the generic rule, keeping the composer <16px -> iOS still zoomed on focus. !important
forces 16px on all fields so the zoom never fires. (#7 latest + budge already verified fixed.)
Build marker -> 2026-07-18-batch110.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device log revealed the truth:
- Keyboard: focusing the <16px composer made iOS ZOOM the page (vvW 428->394, vvTop->74), scrambling
every viewport/keyboard calc. Set all form fields to 16px on mobile -> no zoom -> keyboard math holds.
- Latest messages: on a 500-msg chat atBottom was false because late images grow the thread after the
initial scroll. Now re-pin on a schedule + on each image load while near the bottom.
- Budge already gone (device reported ovf:false).
Build marker -> 2026-07-18-batch109.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Temporary: the app POSTs its real runtime state to the server log so device-only bugs (budge source,
keyboard viewport numbers, chat-open scroll state) can be diagnosed from docker logs instead of
screenshots. Removed once mobile issues settle.
Build marker -> 2026-07-18-batch108.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Keyboard: the 60ms timer in batch106 sampled the viewport MID-animation (clientHeight/visualViewport
temporarily inconsistent) and over-lifted -> gap. Reverted to reading clientHeight-visualViewport LIVE
on every vv resize, which is correct and self-adapting (unit test: residual=46 flush on the device).
- Budge: reverted the file <input> back to display:none (the position:fixed;left:50% hack overflowed
the viewport horizontally).
- Config back to resize:native to match the installed build (works, no rebuild).
- Added test/keyboard-lift.test.js (5/5 pass) verifying the lift math against real device numbers.
Build marker -> 2026-07-18-batch107.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Keyboard: auto-detect resize mode. resize:none -> lift full keyboard height at willShow so the CSS
transition slides the composer up WITH the keyboard (smooth, no iOS resize lag). resize:native ->
residual only. Config set to resize:none for the smooth path (needs build); web stays correct on the
current resize:native build meanwhile.
- #7: force scroll-to-bottom at 0/120/320/600ms on open so a chat always opens on the newest message.
- File picker: anchor the file <input> to a fixed bottom spot (was display:none -> iOS dropped the menu
mid-screen); now it comes up as a bottom sheet even as the composer moves.
Build marker -> 2026-07-18-batch106.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Debug confirmed resize:native shrinks the WebView by the keyboard height, so the composer only needs the
residual lift = clientHeight - visualViewport.height (46px on the test device — the prediction-bar sliver).
Tracked live via VisualViewport so it follows the keyboard. Zero per-device constants. Removed debug bar;
reverted Keyboard config to resize:native (which the logic relies on).
Build marker -> 2026-07-18-batch105.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the hardcoded factor with the real overlap = clientHeight - visualViewport.height (works on any
screen). Adds a temporary on-screen debug line to confirm which signal the device reports, so the final
logic needs no per-device tuning. Debug removed once confirmed.
Build marker -> 2026-07-18-batch104.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Measured from device screenshot: composer at ~678pt from bottom vs 351pt keyboard = 2x overshoot.
Scale the reported height by 0.52 so the composer sits flush on the keyboard. Single tunable const.
Build marker -> 2026-07-18-batch103.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause: the Keyboard plugin reports keyboardHeight in device pixels on this build; CSS needs points,
so the raw value overshot by the devicePixelRatio. Divide by DPR when the raw value exceeds the screen's
point height. Web-only.
Build marker -> 2026-07-18-batch102.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Keyboard plugin's reported height overshot. With resize:none the WebView stays full-size, so
window.innerHeight - visualViewport.height is the EXACT keyboard overlap -> composer lands on the
keyboard. Web-only, works on the resize:none build already installed.
Build marker -> 2026-07-18-batch101.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
resize:native gave contradictory results (covered vs header) because the safe-area plugin fights it.
Set resize:none so the native layer does nothing, and lift the composer purely in JS by keyboardHeight
* KB_LIFT. Once resize:none is built, the lift is web-tunable with zero further builds.
Build marker -> 2026-07-18-batch100.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Isolated test showed 'too high' = resize:native already lifts the composer, so the JS --kb added a
second lift up to the header. Removed it. resize:native is the single mover now.
Build marker -> 2026-07-18-batch99.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
batch92 proved resize:native is a no-op with the safe-area plugin, and batch97 turned off the
safe-area keyboard offset, so re-adding the JS --kb lift makes it the ONLY thing moving the composer
= it sits exactly on the keyboard. Works on the current installed build (web-only).
Build marker -> 2026-07-18-batch98.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #3 keyboard: the overshoot proved resize:native DOES lift the composer; my --kb JS was a second lift.
Removed the JS hack entirely; keyboard is now purely @capacitor/keyboard resize:native. Config: turn
OFF safe-area offsetForKeyboardInsetBug so only one mechanism moves the view (needs the build).
- #7: gate the open-time hide/reveal with _convoRevealed so the cache->network double render doesn't
flash/dance; on mobile open at the LATEST message (skip auto-scroll to first-unread).
- #6: add SplashScreen config (bg #16294F, no spinner) — asset gen already produces the logo.
Build marker -> 2026-07-18-batch97.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The finger-follow edge gesture starting at the very left edge collided with iOS's own edge-swipe,
which fires touchcancel instead of touchend, so cleanup never ran and .content stayed transformed +
chat-dragging stuck on top -> bottom nav hidden and all clicks blocked. Back to the safe release-based
swipe (still gets showWelcome's parallax slide). Keyboard --kb inset, #7 no-dance, #8 image swipe kept.
Build marker -> 2026-07-17-batch96.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #3 keyboard (was B: chat didn't move, keyboard covered composer): native resize wasn't moving our
fixed layout, so drive it from JS — the Keyboard plugin reports its height, we expose --kb and lift the
composer to sit exactly on the keyboard; keyboard config -> resize:none so native won't double-adjust.
- #4: the left-edge back is now INTERACTIVE — the conversation follows the finger with the list revealed
underneath (parallax), commit past 1/3 width else spring back (like Teams). Falls back to release-based
bzcBack for popups/search.
- #8: the image now follows the finger sideways (and down) during the swipe, not just on release.
- #7: hold the thread hidden until its images load (max 700ms) then reveal at the bottom — no dance.
Build marker -> 2026-07-17-batch95.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #4: on back, reveal the chat list UNDERNEATH with a parallax slide-in while the conversation slides
off to the right (slide the whole .content, list forced visible beneath) — matches the Teams push/pop.
- #8: swipe left/right in the image viewer flips to the prev/next image (not just swipe-down to close).
- #7: re-pin to the bottom as each image finishes loading (when near bottom), so late images don't
reflow/'dance' the thread.
Build marker -> 2026-07-17-batch94.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses reported iOS observations (web-side, no rebuild):
- #1: the overflow-x:hidden I added to stop sideways-scroll was clipping the profile/bell popup menus;
now only the chat-list scroller is clipped and long content wraps instead of widening the page.
- #9: swipe a message bubble right to reply (WhatsApp/Teams style).
- #8: swipe an opened image down to dismiss the lightbox.
- #7: hide the thread for one frame on open so messages don't visibly scroll/'dance' into place.
- #4: slide the outgoing conversation off-screen on back (inline transform, reliable across WebKit).
Build marker -> 2026-07-17-batch93.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
App Store Connect rejected the upload: 'The bundle version must be higher than the
previously uploaded version: 1'. Capacitor defaults CFBundleVersion to 1, so every
build collided with the first upload. Patch it to the monotonic BUILD_NUMBER.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device screenshots proved the WebView reports env(safe-area-inset-*)=0, so no web/CSS/JS trick can
move content off the Dynamic Island — it must be fixed natively. Upgrade the Capacitor shell to v7 and
add @capacitor-community/safe-area (populates env() with real per-device insets, edge-to-edge) and
@capacitor/keyboard (resize:native so the composer sits directly on the keyboard, like Teams).
- mobile: Capacitor 6 -> 7 for all plugins; add safe-area + keyboard; SafeArea/Keyboard config.
- codemagic: npm ci -> npm install (dependency set changed); drop stale Cap6 lockfile.
- web: remove the temporary StatusBar/34px hacks (env() now works); keep var(--sat)/var(--sab) padding.
- share/connect: back link now respects env(safe-area-inset-top/left).
- add native-style slide in/out (push/pop) animation when opening/closing a chat on mobile.
Build marker -> 2026-07-17-batch92.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause from device screenshots: the iOS Capacitor WebView reports env(safe-area-inset-*)=0,
so CSS padding did nothing and content slid under the Dynamic Island (every page: home/share/
connect/login) and the bottom nav was clipped.
- Use the already-installed StatusBar plugin: setOverlaysWebView(false) pushes content below the
status bar natively (no rebuild). Applied on all entry pages via a small native-chrome snippet.
- Hard-code the bottom home-indicator inset (--sab:34px on html.native-ios) since env() can't give it.
- #2 keyboard: hide the bottom tab bar inside an open conversation so the composer sits directly on
the keyboard (was sandwiched between composer and keyboard).
- #3 h-scroll: clip every scroll surface + overflow-wrap:anywhere so long content can't widen the page.
Build marker -> 2026-07-17-batch91.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reported on the iOS TestFlight build:
- #1/#5 notch/home-indicator: apply top safe-area inset per surface (chat list column +
content panels) instead of on the whole shell, so headers sit below the Dynamic Island
and nothing is cut off top/bottom.
- #2: opening a chat no longer auto-focuses the composer (was popping the phone keyboard
and shoving the layout up); tapping the box still focuses normally.
- #3: overflow-x:hidden + max-width:100vw guards so pages can't pan left/right.
- #4: left-edge swipe-right now triggers the existing bzcBack() (close popup/search/open
chat) — a native-feeling back gesture with no native code.
- #6: hide the 'Download app' (Windows) button when running inside the Capacitor app.
Build marker -> 2026-07-17-batch90.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The IPA now builds, signs, uploads and processes on App Store Connect. Set
submit_to_testflight=false so the build stays green via internal testing; external
beta review (which needs Test Information + a demo login) is opt-in later. Also set
ITSAppUsesNonExemptEncryption=false in Info.plist so ASC stops prompting for export
compliance on every upload.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fetch-signing-files --create alone mints a throwaway distribution certificate whose
private key dies with the build VM; the next build then finds a cert it has no key
for and fails 'Cannot save Signing Certificates without certificate private key'.
Supplying our own fixed private key (secure var in the ios_signing group) makes the
cert reproducible and reusable across builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The archive failed with 'App requires a provisioning profile' because use-profiles
ran inside the signing step before pod install created the workspace, so the
profile never bound to the App target. Move it to just before build-ipa, matching
Codemagic's Capacitor recipe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The environment.ios_signing block makes Codemagic fetch an EXISTING profile at
build init and fails with 'No matching profiles found' on a brand-new app. The
'Set up code signing' script already creates the cert+profile via
fetch-signing-files --create, so the block was both redundant and blocking.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
iOS WebViews don't support getDisplayMedia, so sharing the device screen into a meeting needs a
native ReplayKit Broadcast Upload Extension (+ App Groups) — viewing a shared screen works today.
Also note native audio routing (AVAudioSession) as a phase-2 plugin. Adjusted the App Review note
so we don't advertise iOS screen-sharing before that extension ships.
The iOS app is a Capacitor shell over the live web UI — the web/server side is already fully
Capacitor-ready (nativePlatform() detects Capacitor; setupNativePush registers APNs tokens via
/api/v1/devices; the APNs sender is built into server/push.js, config-gated). So this adds only
the build/sign/upload path:
- codemagic.yaml: macOS-cloud workflow that generates the iOS project, patches Info.plist,
generates icons/splash, signs via an App Store Connect API key (automatic signing), archives,
and uploads to TestFlight. No Mac required.
- mobile/scripts/ios-patch.sh: adds the App-Review privacy usage strings (camera/mic/photos) +
display name to the generated Info.plist.
- mobile/IOS_SETUP.md: click-by-click runbook — ASC app record, API key, Codemagic integration,
first build, APNs key → server .env, and the public-submission checklist.
Bundle id com.bizgaze.connect. No secrets committed — Apple keys live in Codemagic + server .env.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sendGif ignored replyTarget, so picking a GIF while replying sent a plain message instead of a
reply. It now carries replyTo and clears the reply, like sendMessage. Reply bar + quote show
'GIF' instead of the raw media URL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
aspect-ratio:1 on the grid cells collapsed to ~0 height in the emoji-pop flex column, so the
images overflowed and overlapped. Use grid-auto-rows + a fixed cell height instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Server: GET /api/gifs proxies GIPHY search/trending. The API key is read from the server
env only (config.GIPHY_API_KEY, from the gitignored .env) and NEVER reaches the browser;
the picker is hidden when it isn't configured.
- Emoji picker gains a GIF tab (separated from the emoji categories) with a search box + a 2-col
grid, "Powered by GIPHY" attribution. Clicking a GIF sends it immediately.
- GIFs are HOTLINKED to GIPHY's CDN (their terms require this — no re-hosting): the message body
is the GIF url, and a body that is a lone GIF url renders inline as the animated GIF (reusing
the image/lightbox path). Sidebar previews + notifications show "🎞️ GIF", not the raw url.
Key is NOT in git — set as GIPHY_API_KEY in the server .env.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Revised rule per user: a call is not a meeting. Past meetings now = real SCHEDULED meetings +
INSTANT meetings (Start-a-meeting, logged in call_history as adhoc). Ad-hoc CALLS — 1:1 direct,
group calls, and a 1:1 that a 3rd person joined — are shown ONLY if they produced a
recording/transcript. Dropped the earlier ">2 participants shows it" exception.
Also stop rendering a bare "Host: —" on logged call cards.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#3 The 136 "Direct Call" cards: calls.js writes a scheduled_meetings row for EVERY call
("Direct Call" / "Group call"), which is separate from the call_history table. Those rows
came through the scheduled-meetings path and my earlier filter never touched them. Now an
auto call-history row is shown in Past ONLY if it produced a recording/transcript OR the
call ever held >2 people (peak from the call log); plain 1:1s with neither are dropped.
Removed the now-redundant callRows synthesis. Recordings stay attached (rows WITH a
recording are always kept).
#4 Date range: from ≤ to enforced by disabling out-of-range days in each picker (can't pick a
from after to, or a to before from). Filter controls share one height/baseline so the
calendar icon, preset dropdown and date fields align cleanly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#1 Last seen still read "Offline": last_seen is a NEW column, so every existing user was NULL
until they happened to reconnect. Backfill it from the newest message each user sent (best
evidence we already have), and also stamp it on message send — not just on connect/disconnect
— so it stays fresh if a socket never closes cleanly.
#3 Past-meetings filter is a report filter now: presets (Today, Yesterday, Last 7/30 days, This
month, Year to date, All time) plus a Custom range that uses the SAME branded calendar popup
as the scheduler. The raw <input type=date> looked foreign and behaved differently per browser.
#4 Hover action row overflowed off-panel on SHORT received messages: it's wider than the bubble
and was anchored to the bubble's right edge, so it ran off the left. Received bubbles now
anchor it from the left (growing into the empty space); own messages keep the right anchor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#7 The past list was hard-capped at 12 (a .slice(0,12) in the client) and there was NO record
of how many people were ever in a finished call — so the "only calls with >2 people" rule
was impossible to apply. Added a call_history table: signaling tracks the HIGH-WATER
participant count per room and logs the call when the room tears down (scheduled meetings
are skipped — they already have their own row).
Past meetings now follow the rules asked for:
• a plain 1:1 direct call is NOT listed — unless it produced a recording/transcript
(those already surface as recording entries);
• a call that ever held MORE than 2 people IS listed (e.g. a 1:1 a third person joined),
showing its participant count and duration;
• entries are visible only to people who were actually in the call (or the group).
Server-side pagination (10/page) + a from/to date filter; nothing is double-listed.
#2 Last seen now shows the exact time/date, WhatsApp-style — "last seen today at 1:36 PM",
"last seen yesterday at 10:15 AM", "last seen 14/07/2026 at 9:00 AM" — instead of "10
minutes ago".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#6 Emoji were slow because Twemoji swapped EVERY emoji for an <img> fetched individually
from a CDN — opening the picker fired hundreds of image requests. Now uses the OS's own
colour emoji font: instant, zero network. twemojify() kept as a no-op.
#4 Message hover row reworked: three one-tap reactions (Like/Laugh/Surprised) + the emoji
picker + (own) Edit. Reply / Forward / Copy / Delete moved behind a ⋮ menu. Added Copy.
The row now sits FULLY above the bubble (was top:-14px, overlapping the first text line).
#9 Phone numbers linkify to tel: — mobile gets the OS "call this number?" prompt; desktop has
no dialer so it offers to copy. Regex kept conservative (10–15 digits, needs +/grouping) so
it won't grab amounts, dates or 6-digit meeting codes.
#3 Image preview zooms: wheel + pinch + double-click + ± buttons, drag to pan, keys (+/-/0),
cursor-anchored. Arrows hide while zoomed so panning isn't hijacked.
#8 Upload progress: fetch() can't report upload progress at all, so a large file just said
"uploading…". Switched to XHR (upload.onprogress) → real bar + %, and cancel aborts in flight.
#1 Clicking a sender in a group opens a mini profile card (photo, presence, last seen) with a
Message button that opens the 1:1 (and a view-photo button).
#2 Last seen: new users.last_seen column, stamped on connect and when the last socket drops;
carried on the presence broadcast, so an offline contact reads "Last seen 10 minutes ago"
instead of a bare "Offline".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The check itself is cheap (one ~35-byte response, served from a variable the server reads
once at startup), but it was hooked to BOTH `focus` and `visibilitychange` with no throttle —
and a single alt-tab back into the app fires both, so every refocus cost two redundant
requests. Now: one check per minute at most, never overlapping itself, and it stops checking
entirely once a new build is known (the retry loop takes over).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>