eleven / messenger Public

web: optimistic sends — show events instantly, replace silently on echo (#1372)

Every emitted event now shows in the UI immediately (a local echo), before
the server round-trips it back, and is silently replaced by the authoritative
broadcast when it lands — no flicker. The generic seam:

- transport tags each event with a client id (_cid) inside the ciphertext; the
  server relays the same blob back, so the echo carries it verbatim and pairs
  to its optimistic copy. No server change — E2EE untouched.
- apps.js `optimisticFold` folds an emitted event locally with no server id;
  the authoritative echo re-folds the same event to become the real record.
  Gated behind an explicit `optimistic: true` opt-in, since it relies on the
  reducer being idempotent by the event's own id (the property every reducer
  already needs, as the window re-folds on each thread re-open).

Chat: an outgoing bubble appears at once (pending), keyed on its cid for life
so the confirm patches it in place (never a rebuild -> no photo/embed flash);
if still unconfirmed after 5s it dims with a subtle "Sending..." state. Stays
pending on failure — never auto-removed. Whiteboard: a stroke lands the instant
you lift the pen instead of vanishing until the echo (optimistic: true).

Event vectors updated (the reconstruction is the spec) + optimistic-lifecycle
tests. Native parity to follow.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
James Adam 79456b7d7e21a8332f76f5fce807678b0bae3389 parent bfaa541
8 files changed, +368 −57
  • changelog/2026-07-28-optimistic-sends.md +7 −0
  • test/event-vectors.test.mjs +75 −4
  • web/static/apps.js +18 −0
  • web/static/apps/README.md +29 −0
  • web/static/apps/board.js +7 −0
  • web/static/apps/chat.js +143 −52
  • web/static/shell.js +58 −1
  • web/static/style.css +31 −0
diff --git a/changelog/2026-07-28-optimistic-sends.md b/changelog/2026-07-28-optimistic-sends.md
new file mode 100644
index 0000000..0cd5bde
--- /dev/null
+++ b/changelog/2026-07-28-optimistic-sends.md
@@ -0,0 +1,7 @@
+🤖 **Messages appear the instant you send them.** Your message now shows in the
+conversation immediately, without waiting for the server to acknowledge it — and
+it's replaced silently by the delivered copy once it lands, so nothing flickers.
+If a send is taking a while (a flaky connection), the message dims and shows a
+subtle "Sending…" so you know it's still on its way. The whiteboard gets the same
+treatment: a stroke stays put the moment you lift the pen, instead of flashing
+away and back.
diff --git a/test/event-vectors.test.mjs b/test/event-vectors.test.mjs
index 066f219..eb997ae 100644
--- a/test/event-vectors.test.mjs
+++ b/test/event-vectors.test.mjs
@@ -20,12 +20,34 @@ import "../web/static/apps/webhooks.js";
// The app.hostEvents from web/static/apps/chat.js.
// For testing purposes, we'll reconstruct them here so the test is self-contained.
+// Mirrors chat.js's chat.msg hostEvent — the SPEC, not a snapshot. Includes the
+// optimistic-send lifecycle: an emitted message folds PENDING (meta.pending, a
+// client id `_cid`, no server id) so it shows instantly; its own broadcast echo
+// (same _cid, now with a server id) UPGRADES that record in place rather than
+// duplicating it. Keep this in step with the real fold in web/static/apps/chat.js.
+function bySidPendingLast(a, b) {
+ if (a.sid && b.sid) return a.sid - b.sid;
+ if (!a.sid && !b.sid) return a.ts - b.ts;
+ return a.sid ? -1 : 1;
+}
function testChatMsgFold(state, event, meta) {
const text = typeof event.text === "string" ? event.text : "";
const atts = Array.isArray(event.attachments) ? event.attachments.filter((a) => a && a.id) : [];
const sid = (meta && meta.id) || 0;
- if ((!text.trim() && !atts.length) || !sid) return state;
- if (state.msgs.some((m) => m.sid === sid)) return state;
+ const cid = typeof event._cid === "string" ? event._cid : "";
+ const pending = !!(meta && meta.pending);
+ if ((!text.trim() && !atts.length) || (!sid && !pending)) return state;
+ if (sid && state.msgs.some((m) => m.sid === sid)) return state;
+ if (cid) {
+ const idx = state.msgs.findIndex((m) => m.cid === cid);
+ if (idx >= 0) {
+ if (!sid) return state;
+ const upgraded = state.msgs.slice();
+ upgraded[idx] = { ...upgraded[idx], sid, ts: event.ts || upgraded[idx].ts, pending: false, slow: false };
+ upgraded.sort(bySidPendingLast);
+ return { msgs: upgraded };
+ }
+ }
const raw = { name: event.name || "", text, ts: event.ts || ((meta && meta.ts) || 0) };
if (event.uid) raw.uid = event.uid;
if (atts.length) raw.attachments = atts.map((a) => ({ ...a }));
@@ -36,8 +58,11 @@ function testChatMsgFold(state, event, meta) {
const msgs = [
...state.msgs,
{
- id: "m" + sid,
+ id: sid ? "m" + sid : "c" + cid,
sid,
+ cid,
+ pending,
+ slow: false,
text,
name: raw.name,
uid: event.uid || "",
@@ -53,7 +78,7 @@ function testChatMsgFold(state, event, meta) {
rev: 0,
},
];
- msgs.sort((a, b) => a.sid - b.sid);
+ msgs.sort(bySidPendingLast);
return { msgs };
}
@@ -188,6 +213,52 @@ function test(name, fn) {
}
}
+// Optimistic-send lifecycle (the web/native contract for `_cid`). A message
+// folds PENDING the instant it's emitted, then its own echo silently upgrades
+// that same record — never a duplicate. Native must carry `_cid` through and
+// apply the same pairing.
+test("chat.optimistic: an emitted message folds pending, then its echo upgrades it in place", () => {
+ const cid = "cid-abc";
+ // 1. Optimistic local echo: no server id yet, pending, keyed by cid.
+ let state = testChatMsgFold({ msgs: [] }, { text: "hi there", name: "Ada", uid: "u1", ts: 1000, _cid: cid }, { cid, ts: 1000, pending: true });
+ assert.strictEqual(state.msgs.length, 1, "pending message shows immediately");
+ let m = state.msgs[0];
+ assert.strictEqual(m.sid, 0, "no server id while pending");
+ assert.strictEqual(m.pending, true, "flagged pending");
+ assert.strictEqual(m.cid, cid, "keyed by the client id");
+ assert.strictEqual(m.id, "c" + cid, "keyedList id is cid-stable so the bubble is never rebuilt");
+ // 2. The server broadcasts our own message back (same _cid, now a real id).
+ state = testChatMsgFold(state, { text: "hi there", name: "Ada", uid: "u1", ts: 1000, _cid: cid }, { id: 42, ts: 1000 });
+ assert.strictEqual(state.msgs.length, 1, "echo upgrades in place — no duplicate");
+ m = state.msgs[0];
+ assert.strictEqual(m.sid, 42, "adopts the server id");
+ assert.strictEqual(m.pending, false, "no longer pending");
+ assert.strictEqual(m.slow, false, "slow cleared");
+ assert.strictEqual(m.id, "c" + cid, "keyedList id stays stable across the upgrade");
+ // 3. A duplicate echo (catch-up on reopen) is idempotent.
+ const again = testChatMsgFold(state, { text: "hi there", name: "Ada", uid: "u1", ts: 1000, _cid: cid }, { id: 42, ts: 1000 });
+ assert.strictEqual(again.msgs.length, 1, "re-folding the confirmed message changes nothing");
+});
+
+test("chat.optimistic: a pending copy sorts after confirmed messages, then slots into id order on confirm", () => {
+ let state = testChatMsgFold({ msgs: [] }, { text: "first", ts: 10 }, { id: 100, ts: 10 });
+ state = testChatMsgFold(state, { text: "mine", ts: 20, _cid: "z" }, { cid: "z", ts: 20, pending: true });
+ assert.deepStrictEqual(state.msgs.map((m) => m.text), ["first", "mine"], "pending sits at the bottom");
+ // A higher-id message arriving before our echo still lands before the pending copy.
+ state = testChatMsgFold(state, { text: "later", ts: 30 }, { id: 200, ts: 30 });
+ assert.deepStrictEqual(state.msgs.map((m) => m.text), ["first", "later", "mine"], "pending stays last until confirmed");
+ state = testChatMsgFold(state, { text: "mine", ts: 20, _cid: "z" }, { id: 150, ts: 20 });
+ assert.deepStrictEqual(state.msgs.map((m) => m.text), ["first", "mine", "later"], "on confirm it slots into server order");
+});
+
+test("chat.optimistic: a normal server message carrying _cid still folds its text (native carry-and-ignore)", () => {
+ const state = testChatMsgFold({ msgs: [] }, { text: "hello", name: "Bo", ts: 5, _cid: "unmatched" }, { id: 7, ts: 5 });
+ assert.strictEqual(state.msgs.length, 1, "folds like any message");
+ assert.strictEqual(state.msgs[0].text, "hello", "_cid never leaks into the text");
+ assert.strictEqual(state.msgs[0].sid, 7, "real id");
+ assert.strictEqual(state.msgs[0].pending, false, "not pending — it arrived confirmed");
+});
+
allVectors.chat.forEach((vector) => {
if (vector.expected_fold_event?.type === "chat.msg") {
test(`chat.msg: ${vector.name} — folds correctly and produces expected event shape`, () => {
diff --git a/web/static/apps.js b/web/static/apps.js
index fc326e0..dbd0d53 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -1086,6 +1086,24 @@ export function foldEvent(threadId, event, meta) {
return foldInto(threadId, app, event, meta);
}
+// optimisticFold shows a just-emitted event immediately — a LOCAL echo folded
+// before the server round-trips it back — so a send lands in the UI with no
+// network wait (and, for a drawing, no flash of the line vanishing until the
+// echo). It carries no server message id (meta.id): the fold-level de-dupe is
+// skipped, and the AUTHORITATIVE echo (which does carry one) re-folds the same
+// event to become the real record. That's only safe when the reducer is
+// idempotent by the event's OWN id — the same property every reducer already
+// needs, since the message window re-folds on every thread re-open — so an app
+// opts in explicitly with `optimistic: true` rather than us assuming it. `_cid`
+// (a client id shell.js tucks into the payload) rides back in the echo verbatim,
+// so a surface can pair the pending copy with its confirmation. Chat is host-fed
+// (chat.msg) and handled by shell.js directly.
+export function optimisticFold(threadId, event, cid) {
+ const app = typeOwner.get(event.type);
+ if (!app || !app.reduce || !app.optimistic) return null;
+ return foldInto(threadId, app, event, { cid, ts: event.ts, pending: true });
+}
+
// foldHostEvent folds a HOST-SYNTHESIZED event into a named app, reaching its
// hostEvents handlers — the doorway shell.js uses to feed chat messages and
// server-enforced mutations (reactions, edits, deletes) into the chat app.
diff --git a/web/static/apps/README.md b/web/static/apps/README.md
index c57d5a6..5895de3 100644
--- a/web/static/apps/README.md
+++ b/web/static/apps/README.md
@@ -745,6 +745,35 @@ Encryption is automatic: `emit` encrypts the event to the thread key and sends i
as a normal message. You never touch keys, and never send plaintext to the
server.
+### Optimistic sends (`optimistic: true`)
+
+By default an emitted event only appears once the server broadcasts it back — a
+round-trip's wait, and for a drawing a visible *flash* (the line vanishes on
+pen-up, then reappears). Set **`optimistic: true`** on your app and the framework
+folds each emitted event into your state **immediately**, locally, then lets the
+authoritative echo quietly replace it:
+
+```js
+export const app = defineApp({
+ name: "board",
+ optimistic: true, // a stroke shows the instant you lift the pen
+ // …
+});
+```
+
+How it stays correct: the transport tucks a client id (`_cid`) inside every
+event; the server relays the same ciphertext back, so the echo carries it
+verbatim. The optimistic copy folds with **no server message id**, and the echo
+re-folds the *same event* with the real id. That means your reducer runs on the
+event **twice** — so `optimistic: true` is only safe when your reducer is
+**idempotent by the event's own `id`** (mint it with `ctx.id()`, land it with a
+collection helper like `addItem`). This is the same property every reducer
+already needs — the message window re-folds on each thread re-open — so most apps
+qualify already; opt in explicitly rather than assume it. An app whose reducer
+*counts* or otherwise applies an event more than once per id must **not** set the
+flag. (Chat messages get this treatment built in — an outgoing bubble shows at
+once and wears a subtle "Sending…" state if it's still unconfirmed after 5s.)
+
## `ctx` — what a handler and a render get
| member | in | what it does |
diff --git a/web/static/apps/board.js b/web/static/apps/board.js
index 38187cb..c4d9f00 100644
--- a/web/static/apps/board.js
+++ b/web/static/apps/board.js
@@ -67,6 +67,13 @@ export const app = defineApp({
// to fire this the moment the app is added, so the canvas shows up right
// away rather than sitting invisible until someone remembers to run /board.
openEvent: "board.open",
+ // Optimistic local echo (docs: apps/README.md § Optimistic sends): a finished
+ // stroke folds into state the instant you lift the pen, so the line stays put
+ // instead of vanishing on pen-up and flashing back when the server echoes it.
+ // Safe because every board reducer is idempotent by the event's own id (strokes
+ // via addItem; open/close/clear are self-checked), so the authoritative echo
+ // re-folds onto the same entity rather than duplicating it.
+ optimistic: true,
initialState: () => ({ open: false, strokes: [] }),
events: {
[N.ev("open")]: (s) => (s.open ? s : { ...s, open: true }),
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index 59df70f..c6998d9 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -390,6 +390,15 @@ export function threadItems(msgs, rootSid) {
return out.sort((a, b) => a.sid - b.sid);
}
+// Message order: confirmed messages by server id (the stable identity), with any
+// still-pending optimistic copies (sid 0) after all of them, ordered by send time
+// — so a just-sent bubble sits at the bottom until its echo slots it into place.
+function bySidPendingLast(a, b) {
+ if (a.sid && b.sid) return a.sid - b.sid;
+ if (!a.sid && !b.sid) return a.ts - b.ts;
+ return a.sid ? -1 : 1;
+}
+
export function viewItems(msgs) {
// Reply threads group on the resolved ROOT message: every reply tallies on
// its thread's top message (legacy chains resolve transitively), and each
@@ -426,7 +435,10 @@ export function viewItems(msgs) {
replyCount,
replyRoot: roots.get(m.sid) || 0,
vrev: `${m.rev}|${timeSep ? (timeSep.withDay ? "D" : "t") : ""}|${cont ? 1 : 0}`,
- srev: `${reactSig}|${replyCount}`,
+ // Delivery state rides the SOFT rev: the pending→slow→confirmed transitions
+ // patch the bubble in place (data-id, the "sending…" chip, action affordances)
+ // without rebuilding it — so an optimistic photo never flashes on confirm.
+ srev: `${reactSig}|${replyCount}|${m.pending ? (m.slow ? "slow" : "send") : "ok"}|${m.sid ? 1 : 0}`,
});
prev = m;
}
@@ -464,8 +476,27 @@ export const app = defineApp({
const text = typeof e.text === "string" ? e.text : "";
const atts = Array.isArray(e.attachments) ? e.attachments.filter((a) => a && a.id) : [];
const sid = (meta && meta.id) || 0;
- if ((!text.trim() && !atts.length) || !sid) return s;
- if (s.msgs.some((m) => m.sid === sid)) return s; // re-fold — already present
+ const cid = typeof e._cid === "string" ? e._cid : "";
+ const pending = !!(meta && meta.pending);
+ // A server message needs its id; an optimistic local echo has none yet
+ // (it's pending, keyed only by cid until its own broadcast confirms it).
+ if ((!text.trim() && !atts.length) || (!sid && !pending)) return s;
+ if (sid && s.msgs.some((m) => m.sid === sid)) return s; // re-fold — already present
+ // Our own echo confirming an optimistic copy: upgrade that pending record
+ // IN PLACE — adopt the server id, drop the pending flags — keeping its
+ // stable keyedList id (its bubble is patched, never rebuilt, so a photo or
+ // playing embed never flashes). A second optimistic fold of the same cid
+ // (no sid yet) is a no-op.
+ if (cid) {
+ const idx = s.msgs.findIndex((m) => m.cid === cid);
+ if (idx >= 0) {
+ if (!sid) return s;
+ const msgs = s.msgs.slice();
+ msgs[idx] = { ...msgs[idx], sid, ts: e.ts || msgs[idx].ts, pending: false, slow: false };
+ msgs.sort(bySidPendingLast);
+ return { msgs };
+ }
+ }
const raw = { name: e.name || "", text, ts: e.ts || ((meta && meta.ts) || 0) };
if (e.uid) raw.uid = e.uid;
if (atts.length) raw.attachments = atts.map((a) => ({ ...a }));
@@ -478,8 +509,13 @@ export const app = defineApp({
const msgs = [
...s.msgs,
{
- id: "m" + sid,
+ // A pending optimistic copy keys on its cid for life (stable across the
+ // upgrade to a real sid), so its bubble is never destroyed/recreated.
+ id: sid ? "m" + sid : "c" + cid,
sid,
+ cid,
+ pending, // shown, but not yet acknowledged by the server
+ slow: false, // flipped true by chat.pending after 5s unconfirmed
text,
name: raw.name,
uid: e.uid || "",
@@ -499,7 +535,18 @@ export const app = defineApp({
rev: 0,
},
];
- msgs.sort((a, b) => a.sid - b.sid);
+ msgs.sort(bySidPendingLast);
+ return { msgs };
+ },
+ // A still-unconfirmed optimistic send, 5s on: mark it slow so the bubble
+ // surfaces a subtle "sending…" state (host-fed by shell.js's markSlow timer).
+ [N.ev("pending")]: (s, e) => {
+ const cid = typeof e._cid === "string" ? e._cid : "";
+ if (!cid) return s;
+ const idx = s.msgs.findIndex((m) => m.cid === cid && m.pending);
+ if (idx < 0) return s;
+ const msgs = s.msgs.slice();
+ msgs[idx] = { ...msgs[idx], slow: true };
return { msgs };
},
[N.ev("reaction")]: (s, e, meta, ctx) => {
@@ -1018,7 +1065,7 @@ function renderItem(m, ctx) {
const wrap = document.createElement("div");
wrap.className = "msg " + (mine ? "me" : "them") + (m.cont ? " cont" : "");
if (Date.now() - m.ts < 3000) wrap.classList.add("appear"); // live arrivals ease in, history doesn't
- wrap.dataset.id = m.sid; // lets a reply quote scroll back to this message
+ wrap.dataset.id = m.sid || ""; // its server id (empty while pending) — lets a reply quote scroll back here
wrap.dataset.t = m.ts;
wrap.dataset.sender = m.name;
// Tag with the resolved thread root (not the quoted id) so the "N Replies"
@@ -1157,45 +1204,6 @@ function renderItem(m, ctx) {
if (chips) content.appendChild(chips);
}
- if (!coarse) {
- // Desktop: the hover strip beside the bubble — action icons + exact time,
- // revealed on hover (see .msg-actions in style.css).
- const actions = document.createElement("div");
- actions.className = "msg-actions";
- actions.appendChild(hoverBtn("react-hover", "React", icon("react"), (e) => openMessagePicker(m, ctx, e.currentTarget)));
- actions.appendChild(
- hoverBtn("reply-hover", "Reply", icon("reply"), () => {
- const ctl = composers.get(ctx.threadId);
- if (ctl) ctl.startReply(m);
- }),
- );
- const remaining = m.ts + EDIT_WINDOW_MS - Date.now();
- if (mine && m.text.trim() && remaining > 0) {
- const editBtn = hoverBtn("edit-hover", "Edit", icon("edit"), () => {
- const ctl = composers.get(ctx.threadId);
- if (ctl) ctl.startEdit(m);
- });
- actions.appendChild(editBtn);
- setTimeout(() => editBtn.remove(), remaining); // drop it when the window lapses
- }
- // The exact-time hover affordance doubles as a "Copy link" trigger (issue
- // #893) — the Slack/Discord convention. A <button> (not a bare span) so it's
- // keyboard-reachable and gets a real accessible name, not just visible text
- // that reads as a plain timestamp.
- const timeBtn = document.createElement("button");
- timeBtn.type = "button";
- timeBtn.className = "msg-time";
- timeBtn.textContent = formatTime(m.ts);
- timeBtn.title = "Copy link to this message";
- timeBtn.setAttribute("aria-label", `Copy link to this message, sent ${formatTime(m.ts)}`);
- timeBtn.onclick = (e) => {
- e.stopPropagation();
- copyMessageLink(ctx, m);
- };
- actions.appendChild(timeBtn);
- content.appendChild(actions);
- }
-
wrap.appendChild(content);
// Reactions below the bubble (buildReactionsRow seeds the load-time "mine"
@@ -1213,20 +1221,87 @@ function renderItem(m, ctx) {
wrap.appendChild(e);
}
- // Gestures, per platform like the original feed: touch gets swipe-right to
- // reply plus long-press / double-tap for the picker; desktop gets
- // press-and-hold (the hover strip covers the rest).
- if (coarse) {
- attachSwipeReply(wrap, m, ctx);
- attachTouchPicker(wrap, m, ctx);
+ // A still-pending optimistic send withholds its interactive actions — you can't
+ // react to, reply to, link, or edit a message that hasn't been sent yet — and
+ // wears a subtle status the CSS only reveals once it's `.slow` (5s unconfirmed).
+ // On confirmation patchBubble promotes it: attaches these actions with the real
+ // server id, no rebuild. A confirmed message gets them straight away.
+ if (m.pending) {
+ wrap.dataset.pending = "1";
+ wrap.classList.add("pending");
+ if (m.slow) wrap.classList.add("slow");
+ wrap.appendChild(buildSendStatus());
} else {
- attachPressHold(wrap, m, ctx);
+ attachActions(wrap, content, m, ctx);
}
item.appendChild(wrap);
return item;
}
+// The interactive message actions — desktop's hover strip (react / reply / edit /
+// copy-link) plus its press-and-hold, or touch's swipe-reply + long-press picker.
+// Split out of renderItem so patchBubble can attach them to a bubble that first
+// rendered while pending (actions withheld) the moment its echo confirms it — with
+// the now-real server id, and without rebuilding the bubble (no photo flash).
+function attachActions(wrap, content, m, ctx) {
+ if (coarse) {
+ attachSwipeReply(wrap, m, ctx);
+ attachTouchPicker(wrap, m, ctx);
+ return;
+ }
+ // Desktop: the hover strip beside the bubble — action icons + exact time,
+ // revealed on hover (see .msg-actions in style.css).
+ const mine = ownMessage({ uid: m.uid, name: m.name }, ctx.me, ctx.meName);
+ const actions = document.createElement("div");
+ actions.className = "msg-actions";
+ actions.appendChild(hoverBtn("react-hover", "React", icon("react"), (e) => openMessagePicker(m, ctx, e.currentTarget)));
+ actions.appendChild(
+ hoverBtn("reply-hover", "Reply", icon("reply"), () => {
+ const ctl = composers.get(ctx.threadId);
+ if (ctl) ctl.startReply(m);
+ }),
+ );
+ const remaining = m.ts + EDIT_WINDOW_MS - Date.now();
+ if (mine && m.text.trim() && remaining > 0) {
+ const editBtn = hoverBtn("edit-hover", "Edit", icon("edit"), () => {
+ const ctl = composers.get(ctx.threadId);
+ if (ctl) ctl.startEdit(m);
+ });
+ actions.appendChild(editBtn);
+ setTimeout(() => editBtn.remove(), remaining); // drop it when the window lapses
+ }
+ // The exact-time hover affordance doubles as a "Copy link" trigger (issue
+ // #893) — the Slack/Discord convention. A <button> (not a bare span) so it's
+ // keyboard-reachable and gets a real accessible name, not just visible text
+ // that reads as a plain timestamp.
+ const timeBtn = document.createElement("button");
+ timeBtn.type = "button";
+ timeBtn.className = "msg-time";
+ timeBtn.textContent = formatTime(m.ts);
+ timeBtn.title = "Copy link to this message";
+ timeBtn.setAttribute("aria-label", `Copy link to this message, sent ${formatTime(m.ts)}`);
+ timeBtn.onclick = (e) => {
+ e.stopPropagation();
+ copyMessageLink(ctx, m);
+ };
+ actions.appendChild(timeBtn);
+ content.appendChild(actions);
+ attachPressHold(wrap, m, ctx);
+}
+
+// The pending-send status footer: a spinner + "Sending…", kept out of the tab
+// order and hidden by CSS until the bubble goes `.slow`. `role="status"` +
+// aria-live so a screen reader announces the delay when it finally surfaces
+// rather than the instant every message is sent.
+function buildSendStatus() {
+ const s = document.createElement("div");
+ s.className = "send-status";
+ s.setAttribute("role", "status");
+ s.textContent = "Sending…";
+ return s;
+}
+
// The reactions row below a bubble (or null when there are none). Seeds the
// load-time "mine" flags first — add-only and idempotent (live frames carry
// counts only, never a mine flag), so it's safe to run from BOTH the initial
@@ -1284,6 +1359,22 @@ function patchBubble(node, m, ctx) {
const wrap = node.querySelector(":scope > .msg");
if (!wrap) return;
const content = wrap.querySelector(":scope > .content");
+ // Delivery state (soft rev). A bubble that rendered while pending either goes
+ // `.slow` (5s unconfirmed) or gets PROMOTED here the moment its echo lands: drop
+ // the pending chrome, adopt the real server id, and attach the interactive
+ // actions it was withheld — all outside .content, so the bubble is never rebuilt.
+ if (wrap.dataset.pending === "1") {
+ if (m.pending) {
+ wrap.classList.toggle("slow", !!m.slow);
+ } else {
+ wrap.dataset.pending = "0";
+ wrap.classList.remove("pending", "slow");
+ wrap.dataset.id = m.sid || "";
+ const status = wrap.querySelector(":scope > .send-status");
+ if (status) status.remove();
+ attachActions(wrap, content, m, ctx);
+ }
+ }
// Reactions: rebuild the row and reinsert it right after .content, so it stays
// above the reply/edited tags regardless of which of them are present.
const oldRow = wrap.querySelector(":scope > .reactions");
diff --git a/web/static/shell.js b/web/static/shell.js
index 2eb0983..364577d 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -81,6 +81,7 @@ import {
isAppType,
foldEvent,
foldHostEvent,
+ optimisticFold,
deliverEphemeral,
appPreview,
appUnread,
@@ -4335,18 +4336,71 @@ setEmitTransport(async (threadId, event) => {
// giving up — the web app proper never hits this (it unwraps every thread).
if (!key && embed && (await requestNativeKey(threadId))) key = threadKeys[threadId];
if (!key || !ws || ws.readyState !== WebSocket.OPEN) return false;
- const payload = { ...event, name: session.name, ts: Date.now() };
+ // A client id for this event: it rides inside the ciphertext (the server never
+ // sees it), and the server relays the SAME blob back, so the echo carries it
+ // verbatim — that's how we pair our optimistic local copy with its
+ // authoritative confirmation and replace it silently (see optimisticEmit).
+ const cid = newCid();
+ const payload = { ...event, name: session.name, ts: Date.now(), _cid: cid };
if (myUserId) payload.uid = myUserId;
// Tuck our verified chain head into the encrypted payload (tamper-evidence
// gossip) — peers compare it against their own view; apps never see it.
const head = integrity.gossipOut();
if (head) payload._ckpt = head;
+ optimisticEmit(threadId, payload, cid);
return encrypt(key, payload).then((blob) => {
ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob }));
return true;
});
});
+// A fresh client id for an outgoing event (crypto.randomUUID where available; a
+// cheap unique fallback otherwise — it only needs to be unique within this tab).
+let cidSeq = 0;
+function newCid() {
+ if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();
+ return "c" + Date.now() + "-" + (cidSeq++);
+}
+
+// Optimistic local echo: fold our own event straight into its surface so a send
+// appears with no network wait, then let the server's broadcast quietly replace
+// it. A CHAT message becomes a pending bubble (and, if unconfirmed after 5s, a
+// subtle "sending…" state via markSlow); a typed app event folds only when its
+// app opted in (optimistic: true) with an id-idempotent reducer, so the echo
+// re-fold lands on the same entity rather than duplicating it.
+function optimisticEmit(threadId, payload, cid) {
+ if (payload.type) {
+ if (optimisticFold(threadId, payload, cid) && threadId === activeId) renderActiveSurfaces();
+ return;
+ }
+ if ((chatAppFor(threadId) || threadApp[threadId]) && (payload.text || payload.attachments)) {
+ foldHostEvent(threadId, "chat", chatMsgEvent(payload), { cid, ts: payload.ts, pending: true });
+ if (threadId === activeId) renderActiveSurfaces();
+ markSlow(threadId, cid);
+ }
+}
+
+// Pending optimistic sends, keyed by client id → a timer that, 5s after emit and
+// still unconfirmed, flips the bubble to a subtle "sending…" state (delivery is
+// taking a while and might not have gone through). The echo clears it. We never
+// remove the optimistic copy on failure — stay-pending — only surface the delay.
+const slowTimers = new Map();
+function markSlow(threadId, cid) {
+ const t = setTimeout(() => {
+ slowTimers.delete(cid);
+ foldHostEvent(threadId, "chat", { type: "chat.pending", _cid: cid });
+ if (threadId === activeId) renderActiveSurfaces();
+ }, 5000);
+ slowTimers.set(cid, t);
+}
+function clearSlow(cid) {
+ const t = slowTimers.get(cid);
+ if (t) {
+ clearTimeout(t);
+ slowTimers.delete(cid);
+ }
+}
+
// Connected third-party services (docs/connected-services.md). The privileged
// half of the connect app — the parts an app can't do itself because they need
// the session keypair, roster public keys, and the socket. Two moves:
@@ -7698,6 +7752,9 @@ function connectWS() {
if (applyMembershipDelta(tid, payload) && tid !== activeId) renderThreadList();
}
if ((chatAppFor(tid) || threadApp[tid]) && payload && !payload.type && (payload.text || payload.attachments)) {
+ // Our own echo landing: the fold upgrades the pending bubble in place
+ // (matched on _cid) — cancel its "sending…" timer so it never trips.
+ if (payload._cid) clearSlow(payload._cid);
foldHostEvent(tid, "chat", chatMsgEvent(payload), { id: msg.id, ts: payload.ts });
if (tid === activeId) renderActiveSurfaces();
}
diff --git a/web/static/style.css b/web/static/style.css
index c1745df..dc69fa7 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -588,6 +588,37 @@ select:disabled { opacity: 0.5; cursor: default; }
/* persistent "Edited" label under an edited bubble */
.edited-tag { font-size: 11px; color: var(--muted); margin: 1px 6px 0; }
+/* Optimistic send: an outgoing message renders instantly (`.pending`) and looks
+ completely normal — a sub-second send is never decorated. Only once it's gone
+ unconfirmed for a while (`.slow`, flipped 5s in) does the bubble dim and a
+ "Sending…" status appear, hinting delivery is taking a while and may not have
+ gone through. The text carries the meaning (not colour alone); the spinner is
+ decoration and stops under Reduce Motion. */
+.send-status {
+ display: none;
+ align-items: center;
+ gap: 5px;
+ font-size: 11px;
+ color: var(--muted);
+ margin: 1px 6px 0;
+}
+.msg.me .send-status { align-self: flex-end; }
+.msg.slow .content { opacity: 0.6; } /* dims the bubble/photo/card together; the status sits outside .content */
+.msg.slow .send-status { display: inline-flex; }
+.send-status::before {
+ content: "";
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ border: 1.5px solid currentColor;
+ border-top-color: transparent;
+ animation: send-spin 0.7s linear infinite;
+}
+@media (prefers-reduced-motion: reduce) {
+ .send-status::before { animation: none; border-top-color: currentColor; opacity: 0.4; }
+}
+@keyframes send-spin { to { transform: rotate(360deg); } }
+
/* Hover affordances live in a full-height strip beside the bubble: the hover zone
spans the whole message height, so you can move straight from anywhere in the
bubble to an icon (no need to find the vertical centre), and the action icons +