Messages could silently stop arriving until a room switch happened to
paper over it with a REST refetch. Root cause: a connection that dies
without a clean close (laptop sleep, an idle connection recycled by a
proxy/NAT) looks identical to a healthy-but-quiet one -- readyState
stays OPEN, onclose never fires, so the already-correct reconnect and
rejoin logic never runs.
The backend now pings every 30s and the frontend force-closes the
socket if nothing (any frame, not just a pong) arrives within 65s,
routing straight through the existing reconnect/rejoin path instead of
needing separate recovery logic.
Also found and fixed a more severe adjacent bug while tracing this:
ConnectionManager.broadcast()/send_to_user() had no per-socket error
handling, so one dead or racing socket could raise inside the app's
single, never-restarted Redis pubsub listener task and silently kill
live delivery for every room and every user on that process until
restart. Now caught and logged per-socket instead of propagating.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two independent gaps, both fixed since the report was ambiguous about
which "chats" meant:
- ChatPane.tsx concatenated history (REST-fetched) and live (WS-pushed)
without sorting, so anything that could desync receipt order from
send order -- a rejoin/resync racing a still-in-flight WS message,
which opening the same room on another device triggers directly via
a fresh socket connection -- could render messages out of
chronological order. Now sorted by created_at (stable sort, so
same-timestamp messages keep their relative order).
- list_member_rooms/list_open_rooms/list_recent_messages ordered by
created_at alone, with no secondary tiebreaker. Postgres doesn't
guarantee a stable order for tied rows across separate query
executions, so two rooms/messages sharing an identical timestamp
(a real possibility -- rapid sends, bulk-created rooms) could come
back in a different order on two separate fetches, i.e. two devices.
Added id as a secondary sort key everywhere this showed up.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the Phase 1 placeholder UI with a single persistent app shell (top
bar + sidebar + chat pane, 860px responsive breakpoint) matching the
"PWA chat system UI" design handoff: message bubbles with consecutive-run
avatar/name grouping, auto-growing composer, room search, and the real
DarkSingularity logo (also used to regenerate the PWA icons).
The handoff didn't cover Phase 2 (private rooms, roles, invites) or
browsing/joining open rooms, so those are added using the same visual
language: a room info panel with role badges, invite-by-username with a
pending-invites list, member management (remove/promote/demote/transfer
ownership), room settings (rename/describe/delete), and separate
browse-rooms/invites-inbox modals. Unread badges, last-message preview, and
the typing indicator are deliberately deferred -- both need new backend
features (read-tracking, a WS typing event) that weren't in scope this pass.
Two small backend additions round out data the new UI needs but the API
didn't expose: MessageRead.username (historic messages had no sender name)
and InviteRead.target_username / MyInviteRead.room_name+invited_by_username
(a recipient's invite list can't otherwise resolve a room they're not in).
Both are additive; 35 backend tests still pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Invite-only FastAPI + SQLAlchemy(async) + Postgres backend (session-cookie
auth via CLI-provisioned accounts, open-room CRUD, single-instance /ws/chat)
and a React + Vite PWA frontend (login, room list, chat view). Backend tests
pass against a local Postgres DB. See README.md and backend/README.md for
setup, and ARCHITECTURE.md for the full phased design.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>