feat(ui): optimistic local echo for composer sends (LC-230) #294

Merged
nrupard merged 2 commits from feat/lc-230-optimistic-echo into main 2026-06-01 18:14:54 +02:00
Owner

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-request generates a UUID client_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_message sanitizes the id ([A-Za-z0-9-], max 64; invalid ids are dropped, never rejected) and threads it through finalize_message_send into ChatEvent::NewMessage. 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 pre-LC-230 shape. live.js removes the matching placeholder in htmx: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 own client_id so rapid-fire concurrent sends each flip their own placeholder. Retry resubmits through the composer form (fresh client_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-id on room/message.html via a MessageView.client_id field. Implemented instead on the OOB wrapper in ws/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

  • New 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 renders data-lc-client-id only when set.
  • just check, just test, just test-saas all green (including the LC-77 render fixtures, which stay byte-identical because non-echo renders pass client_id: None).
  • New i18n keys (room-composer-echo-sending, room-composer-echo-discard) added to en + es.
  • Manual smoke still needed on staging: kill the WS in DevTools, send, verify placeholder persists then dedupes on reconnect; 100-rapid-sends double-render check.

🤖 Generated with Claude Code

Closes [LC-230](https://yt.a8n.run/issue/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-request` generates a UUID `client_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_message` sanitizes the id (`[A-Za-z0-9-]`, max 64; invalid ids are dropped, never rejected) and threads it through `finalize_message_send` into `ChatEvent::NewMessage`. 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 pre-LC-230 shape. `live.js` removes the matching placeholder in `htmx: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 own `client_id` so rapid-fire concurrent sends each flip their own placeholder. Retry resubmits through the composer form (fresh `client_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-id` on `room/message.html` via a `MessageView.client_id` field. Implemented instead on the OOB wrapper in `ws/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 - New `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 renders `data-lc-client-id` only when set. - `just check`, `just test`, `just test-saas` all green (including the LC-77 render fixtures, which stay byte-identical because non-echo renders pass `client_id: None`). - New i18n keys (`room-composer-echo-sending`, `room-composer-echo-discard`) added to en + es. - Manual smoke still needed on staging: kill the WS in DevTools, send, verify placeholder persists then dedupes on reconnect; 100-rapid-sends double-render check. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(ui): optimistic local echo for composer sends (LC-230)
All checks were successful
check-secrets / Nosey parker (push) Successful in 3s
check-secrets / Kingfisher (push) Successful in 4s
check-secrets / TruffleHog (push) Successful in 5s
check-secrets / Nosey parker (pull_request) Successful in 4s
check-secrets / Kingfisher (pull_request) Successful in 5s
check-secrets / TruffleHog (pull_request) Successful in 5s
Check / clippy + fmt + tests (pull_request) Successful in 1m58s
ddfc493b1f
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>
fix(ui): address LC-230 review findings - quarantine ghost, retry data loss, debounced failure flip
All checks were successful
check-secrets / Nosey parker (push) Successful in 3s
check-secrets / TruffleHog (pull_request) Successful in 4s
check-secrets / Nosey parker (pull_request) Successful in 4s
check-secrets / TruffleHog (push) Successful in 5s
check-secrets / Kingfisher (pull_request) Successful in 6s
check-secrets / Kingfisher (push) Successful in 7s
Create release / Create release from merged PR (pull_request) Has been skipped
Check / clippy + fmt + tests (pull_request) Successful in 2m5s
c78d54bb9e
Code-review follow-up on PR #294; addresses 7 findings.

- Quarantine ghost row (high): the link-filter quarantine path returns 204 without ever broadcasting, so the optimistic placeholder sat as "Sending..." forever. The quarantine response now carries an X-LC-Echo-Drop header and the composer removes the placeholder on seeing it. Removal (not a "held for review" label) preserves quarantine's existing posture of not revealing the filter to the poster. New test: quarantined_post_signals_echo_drop_and_broadcasts_nothing.
- Retry data loss (high): the failed-row Retry button submitted whatever was in the textarea, silently destroying the failed message's body if the user had started typing something new. Retry now always resends the row's own body: it stashes the in-progress draft, submits the failed body through the normal form path, and restores the draft (requestSubmit serializes synchronously, so the restore cannot race).
- Conditional textarea clear: the after-request success branch now clears the textarea only when it still holds exactly what that request sent. This protects the restored draft above and also fixes a pre-existing fast-typist wipe (send, keep typing, response lands, in-progress text gone).
- Debounced failure flip (med): the placeholder failed-state flip now sits on the same 2s debounce as the LC-216 banner, so a slow-but-actually-posted request whose canonical render arrives within the window never shows a live Retry button (which could double-post). The flip is a no-op when the WS dedupe has already removed the pending row.
- live.js dedupe (low): dropped the dead querySelector fallback; htmx 2.0.4 passes the wrapper element itself as detail.fragment for beforeend OOB swaps, so the non-echo hot path (typing, badges, reactions) is now one null attribute read.
- Placeholder fidelity (low): the pending row now mirrors the canonical row chrome (h-6 w-6 avatar cloned from the always-present call-root self avatar, items-center, font-medium) so the swap to the canonical render no longer shifts horizontally. Fixed the selfName comment that misattributed the avatar element to the sidebar.
- i18n in JS (low): echo UI strings now ride as data attributes on the form and are read via getAttribute, instead of being interpolated into JS string literals where HTML entities would display literally for locales containing apostrophes.
- Scroll-on-send: kept the unconditional jump-to-bottom (standard chat-client convention) and documented it as a deliberate divergence from auto_scroll's followIfPinned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nrupard deleted branch feat/lc-230-optimistic-echo 2026-06-01 18:14:54 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!294
No description provided.