feat(ui): optimistic local echo for composer sends (LC-230) #294
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/lc-230-optimistic-echo"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes LC-230.
The just-sent message appears in the chat list within one paint frame of hitting Enter, before the POST round-trip completes. Closes the "did it send?" perception gap when the WS broadcast lags (reconnects, network spikes, mobile carrier delay). Final piece of the composer-feel series (LC-227 #271, LC-228 #292, LC-229 #293).
How it works
Send path: the composer's
hx-on::config-requestgenerates a UUIDclient_id, injects it into the form params, and appends a minimal pending placeholder (plain-text body, viewer's display name, dimmed "Sending..." style) to#messages. Skipped for slash commands (no message will post), attachment-only sends, and the textarea's draft-autosave PUT.Dedupe path (option A from the ticket):
post_messagesanitizes the id ([A-Za-z0-9-], max 64; invalid ids are dropped, never rejected) and threads it throughfinalize_message_sendintoChatEvent::NewMessage. The per-connection WS render emits it asdata-lc-client-idon the OOB wrapper for the author's own connections only; every other viewer's frame stays byte-identical to the pre-LC-230 shape.live.jsremoves the matching placeholder inhtmx:oobBeforeSwap, so placeholder removal and canonical insert land in the same swap cycle: no double render, no flicker gap.Failure path:
hx-on::after-request(failure branch) flips that request's placeholder to a failed row with Retry / Discard controls, keyed by the request's ownclient_idso rapid-fire concurrent sends each flip their own placeholder. Retry resubmits through the composer form (freshclient_id, fresh placeholder, normal failure handling); the LC-216 banner still fires as the global indicator.Deviation from the ticket AC
The AC proposed
data-client-idonroom/message.htmlvia aMessageView.client_idfield. Implemented instead on the OOB wrapper inws/new_message.html(NewMessageFragment.client_id): the attribute only needs to exist at swap time (live.js reads the incoming fragment), and this avoids threading a transient field through MessageView's 9 construction sites. Same dedupe guarantee, smaller surface.Out of scope (per ticket)
Edit/delete/reaction optimistic rendering; pending-message persistence across reloads (LC-98 outbox covers long-offline); thread panel echo.
Testing
server/tests/routes_optimistic_echo.rs: client_id echoes on the hub broadcast; legacy posts (no field) broadcast None; sanitization drops bad ids without rejecting the send; OOB wrapper rendersdata-lc-client-idonly when set.just check,just test,just test-saasall green (including the LC-77 render fixtures, which stay byte-identical because non-echo renders passclient_id: None).room-composer-echo-sending,room-composer-echo-discard) added to en + es.🤖 Generated with Claude Code
The just-sent message now appears in the chat list within one paint frame of hitting Enter, before the network round-trip completes. Previously the message only rendered when the WS broadcast landed, so under WS reconnects / network spikes the user saw a cleared textarea with no message ("did it send?" gap). Server side: MessageForm gains an optional client_id field (sanitized to [A-Za-z0-9-], max 64 chars; invalid ids are dropped, never rejected). post_message threads it through finalize_message_send into ChatEvent::NewMessage, and the per-connection WS render emits it as data-lc-client-id on the OOB wrapper for the author's own connections only - every other viewer's frame stays byte-identical to the previous shape. All non-composer senders (slash, polls, API, scheduled, reply-by-email, webhooks, bridges, quarantine release, call notices) pass None. Client side: the composer's hx-on::config-request generates a UUID, injects it into the form params, and appends a minimal pending placeholder (plain-text body, dimmed, "Sending...") to #messages. live.js removes the matching placeholder in htmx:oobBeforeSwap when the canonical render arrives, so removal and insert land in the same swap cycle (no double render, no gap). On request failure the placeholder flips to a failed row with Retry / Discard controls; retry resubmits through the composer form so it gets a fresh client_id and normal failure handling. Deviation from the ticket's AC: the dedupe attribute lives on the OOB wrapper (ws/new_message.html) rather than on room/message.html, which avoids threading a transient field through MessageView's 9 construction sites; the attribute never needs to survive the swap because live.js reads it from the incoming fragment. New i18n keys: room-composer-echo-sending, room-composer-echo-discard (en + es). Tests: server/tests/routes_optimistic_echo.rs pins the form contract (echo on broadcast, None for legacy clients, sanitization drop-not-reject) and the wrapper attribute render. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>