eleven / messenger Public

web: queue the send behind an in-flight upload (#269)

Pressing Enter (or tapping send) while a photo/file is still uploading no
longer does nothing: the send button swaps its arrow for a spinner and the
message goes out automatically the moment the last upload lands. Typing
again, Escape, emptying the composer, or a failed upload cancels the queued
send. A multi-file drop counts the whole staging batch so the auto-send
never fires between files of one drop.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell b5e3c1361ba4c12a11785702e4f2e7dee2721cf4 parent d5caba9
4 files changed, +111 −2
  • .claude/skills/verify/SKILL.md +49 −0
  • changelog/2026-07-06-send-waits-for-upload.md +4 −0
  • web/static/apps/chat.js +46 −2
  • web/static/style.css +12 −0
diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md
new file mode 100644
index 0000000..2202257
--- /dev/null
+++ b/.claude/skills/verify/SKILL.md
@@ -0,0 +1,49 @@
+# 🤖 verify — drive the web app end-to-end against a local build
+
+How to observe a web/chat change in a real browser, fast (no fixtures in the
+repo; everything is provisioned over the real protocol).
+
+## Build + run
+
+```sh
+go build -o /tmp/lchat .
+LCHAT_ADMIN_TOKEN=verifytok LCHAT_DB=/tmp/verify.db ADDR=127.0.0.1:18711 /tmp/lchat &
+```
+
+## Provision a member + thread from Node (no UI clicking)
+
+`web/static/crypto.js` runs under Node (`globalThis.crypto = webcrypto`):
+
+1. `randomSecret()` → `deriveInvite(T)` → `{id, claimHash, claimSecret}`.
+2. `POST /api/admin/key-invites` (Bearer admin) `{label, id, wrapped:"", claim_hash}`.
+3. `POST /api/key-invite/{id}/claim` `{claim_secret}` → `{token}`.
+4. `POST /api/pubkey` `{token, public_key}` (from `generateUserKeypair()`).
+5. `GET /api/roster?token=` → **`{roster: [...]}`** — find yourself by `public_key`.
+6. `generateKey()` → `{key, b64}`; `wrapThreadKey(b64, myPub)`;
+ `POST /api/threads` `{token, kind:"group", name_blob: encrypt(key,{name}), members:[{user_id, wrapped}]}` — a group of one is valid and mounts the real chat engine (a `self` thread mounts a mini-app instead).
+
+## Drive it in Chromium (Playwright)
+
+Seed the session *before* page scripts run, then assert on the live DOM:
+
+```js
+await ctx.addInitScript(([sess, tid]) => {
+ localStorage.setItem("lchat_session", JSON.stringify(sess)); // {token,name,userPub,userPriv}
+ localStorage.setItem("lchat_active", tid);
+}, [session, threadId]);
+```
+
+- Live selectors: `.chatapp-root .composer textarea`, `.chatapp-root .send-btn`,
+ `.chatapp-root .composer input[type=file]`, `.chatapp-feed .msg`,
+ `.composer-preview .preview-chip`. (The id-based `#feed`/`#text` skeleton is the
+ retired classic chat — always empty.)
+- Slow/failing uploads: `page.route("**/api/upload*", …)` with a delay or a 500 —
+ perfect for in-flight-upload races.
+- `alert()` paths: register `page.on("dialog", …)` or the page wedges.
+- Test users render as "Someone" unless they publish encrypted labels — harmless.
+- A known Playwright install lives at
+ `~/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs`
+ (browsers in `~/Library/Caches/ms-playwright`); `npx playwright` works too.
+
+Worked example (provision + 14-assertion drive): the scripts from the
+send-waits-for-upload verification — re-create from this recipe.
diff --git a/changelog/2026-07-06-send-waits-for-upload.md b/changelog/2026-07-06-send-waits-for-upload.md
new file mode 100644
index 0000000..6cefa0e
--- /dev/null
+++ b/changelog/2026-07-06-send-waits-for-upload.md
@@ -0,0 +1,4 @@
+🤖 Pressing send while a photo or file is still uploading no longer does
+nothing — the send button shows a little spinner, and your message goes out by
+itself the moment the upload finishes. Typing more, or pressing Escape, cancels
+the queued send so nothing leaves before you're ready.
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index ae61cb0..2e40327 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -2215,6 +2215,8 @@ function buildSkeleton(el, ctx) {
let replyTo = null; // { id, name, snippet } — the original payload shape
let editing = null; // the item being edited
let pending = []; // staged attachments: { att | null (uploading), name, isImage }
+ let sendQueued = false; // Enter landed mid-upload — auto-send once the uploads finish
+ let staging = 0; // stageFiles batches in flight — a queued send waits for the whole batch
const refreshBanners = () => {
replyBanner.textContent = "";
@@ -2339,6 +2341,10 @@ function buildSkeleton(el, ctx) {
pending = pending.filter((q) => q !== p);
refreshStaged();
refreshSend();
+ // Removing a chip can resolve a queued send either way: nothing left to
+ // send cancels it; removing the last still-uploading chip releases it.
+ if (sendQueued && !input.value.trim() && !pending.length) setSendQueued(false);
+ else fireQueuedSend();
};
chip.appendChild(x);
preview.appendChild(chip);
@@ -2351,6 +2357,21 @@ function buildSkeleton(el, ctx) {
send.hidden = input.value.trim() === "" && pending.length === 0;
send.disabled = pending.some((p) => !p.att) || !connected;
};
+ // Enter during an upload queues the send instead of dropping it: the button
+ // swaps its arrow for a spinner (.send-waiting) and the message goes out the
+ // moment the last upload lands. Typing again, a failed upload, or emptying
+ // the composer cancels the queue — the auto-send must never fire on content
+ // that no longer matches what was queued.
+ const setSendQueued = (on) => {
+ sendQueued = on;
+ send.classList.toggle("send-waiting", on);
+ refreshSend();
+ };
+ const fireQueuedSend = () => {
+ if (!sendQueued || staging > 0 || pending.some((p) => !p.att)) return;
+ setSendQueued(false);
+ compose.requestSubmit();
+ };
composers.set(ctx.threadId, {
refreshSend,
@@ -2385,9 +2406,21 @@ function buildSkeleton(el, ctx) {
if (inEmbed) host.native({ error: msg });
else alert(msg);
};
- // One staging path — attach button, paste, and drag-drop all land here.
+ // One staging path — attach button, paste, and drag-drop all land here. The
+ // wrapper counts in-flight batches so a queued send only fires once the whole
+ // drop/paste has staged — mid-batch, later files aren't in `pending` yet, and
+ // firing early would send the message without them.
const stageFiles = async (files) => {
if (!host || !host.upload) return;
+ staging++;
+ try {
+ await stageFilesBatch(files);
+ } finally {
+ staging--;
+ fireQueuedSend();
+ }
+ };
+ const stageFilesBatch = async (files) => {
for (const file of files) {
const isImage = !!(file.type && file.type.startsWith("image/"));
const isVideo = !!(file.type && file.type.startsWith("video/"));
@@ -2416,6 +2449,9 @@ function buildSkeleton(el, ctx) {
if (att && typeof att === "object") entry.att = att;
else {
pending = pending.filter((q) => q !== entry);
+ // A rejected upload cancels any queued send — the message would go out
+ // missing an attachment the sender believed was included.
+ if (sendQueued) setSendQueued(false);
// The bridge returns a reason string (not a bare null) so each rejection
// gets the original composer's distinct wording.
const msg = att === "photo" ? "Couldn't add that photo. Try a smaller one."
@@ -2662,6 +2698,7 @@ function buildSkeleton(el, ctx) {
// stranded a line short of the bottom (the visible "jump" on WebKit / the Mac
// app). Guard on feedAtBottom so typing never yanks a reader who scrolled up.
if (feedAtBottom) feed.scrollTop = feed.scrollHeight;
+ if (sendQueued) setSendQueued(false); // still composing — don't auto-send under them
refreshSend();
updateTypeahead();
// A "/command" in progress is private — never announce typing for it (and
@@ -2693,6 +2730,10 @@ function buildSkeleton(el, ctx) {
compose.requestSubmit();
return;
}
+ if (e.key === "Escape" && sendQueued) {
+ setSendQueued(false); // un-queue first; a second Escape cancels reply/edit
+ return;
+ }
if (e.key === "Escape" && (editing || replyTo)) {
const wasEditing = !!editing;
cancelReply();
@@ -2728,7 +2769,10 @@ function buildSkeleton(el, ctx) {
compose.onsubmit = (ev) => {
ev.preventDefault();
- if (pending.some((p) => !p.att)) return; // still uploading
+ if (pending.some((p) => !p.att)) {
+ setSendQueued(true); // still uploading — send as soon as it lands
+ return;
+ }
const text = emojify(input.value.trim()); // :tada: → 🎉, like the original composer
const resetInput = () => {
input.value = "";
diff --git a/web/static/style.css b/web/static/style.css
index 432e30f..bc4bc8a 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -1237,6 +1237,18 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
}
.send-btn[hidden] { display: none; }
.send-btn:disabled { opacity: 0.45; cursor: default; }
+/* A send queued behind an in-flight upload (Enter mid-upload — see chat.js
+ sendQueued): swap the arrow for a spinner, at full strength so it reads as
+ "working", not the greyed can't-send state. */
+.send-btn.send-waiting svg { display: none; }
+.send-btn.send-waiting:disabled { opacity: 1; }
+.send-btn.send-waiting::before {
+ content: "";
+ width: 16px; height: 16px; border-radius: 50%;
+ border: 2px solid color-mix(in srgb, var(--accent-ink) 35%, transparent);
+ border-top-color: var(--accent-ink);
+ animation: spin 0.8s linear infinite; /* @keyframes spin, top of file */
+}
/* Enlarge the send button's tap target — mostly to the right, into the composer's
padding — so a near-miss on touch triggers Send instead of falling through to the
field/feed and dismissing the keyboard. */