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>
4 files changed,
+111
−2
.claude/skills/verify/SKILL.md+49 −0changelog/2026-07-06-send-waits-for-upload.md+4 −0web/static/apps/chat.js+46 −2web/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. */ |