oficina / meet Public

chat.js: an accepted send no longer eats a newer draft

Codex S4-3 (P2). The textarea stays editable while a message is in
flight — a chat you cannot type in for the length of a round trip is a
chat that feels broken — so `input.value = ''` on acceptance clears
whatever is in the box at that moment, which is not necessarily the
text that was sent. Send "first message", type "second draft" while the
promise is pending, resolve true: the unsent second draft disappeared.

The draft carries a revision now. Every `input` event bumps it, a
submit captures the one it left on, and acceptance clears the box only
if nothing has been typed since. Clearing the box in code is not an
edit and does not bump it, so an untouched box still empties exactly as
it did. The refusal path never wrote to the box and still does not.

Three tests, the first seen failing first ('' !== 'second draft'): a
deferred send edited before acceptance, the same before a refusal, and
two consecutive accepted sends proving the counter has not quietly
stopped clearing.

node --check, 167 node tests, make ci.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
Paul Campbell pushed by paul@keymail.dev a3a722f0d2a687b598145c800b5298267ce282e0 parent 4bbadf3
2 files changed, +85 −1
  • internal/meet/static/chat.js +16 −1
  • test/browser/chat.test.mjs +69 −0
diff --git a/internal/meet/static/chat.js b/internal/meet/static/chat.js
index 43366b0..07918ee 100644
--- a/internal/meet/static/chat.js
+++ b/internal/meet/static/chat.js
@@ -134,6 +134,15 @@
// does not re-enable a button over an over-long draft.
let enabled = true;
let sending = false;
+ // The draft's own revision, bumped by every edit. The box stays
+ // editable while a message is on its way — a chat you cannot type
+ // in for the length of a round trip is a chat that feels broken —
+ // which makes the acceptance path a race: `input.value = ''` clears
+ // whatever is in the box *then*, and that is not necessarily the
+ // text that was sent. A send captures the revision it left on, and
+ // clears the box only if nothing has been typed since (Codex S4-3).
+ // Clearing it here is not an edit and does not bump this.
+ let revision = 0;
// What the status line is currently saying, tracked here rather
// than read back off the element: the element is the host's to
// restyle or replace, and a panel that decides what to clear by
@@ -163,6 +172,7 @@
// that was refused. Either way the old sentence is about text that
// no longer exists.
function onInput() {
+ revision += 1;
if (notice === SEND_FAILED) say('');
refresh();
}
@@ -184,6 +194,11 @@
// drops focus to the body; anyone who was inside the panel when
// they sent gets the caret back in the box afterwards.
const inPanel = root.contains(doc.activeElement);
+ // The revision this message leaves on. Anything typed while it is
+ // in flight moves it, and then the box is not this send's to
+ // empty — neither on acceptance nor on refusal, which never
+ // wrote to the box anyway and must not start.
+ const sentAt = revision;
sending = true;
refresh();
// The host's send may return a promise, a plain boolean, or throw;
@@ -204,7 +219,7 @@
return;
}
say('');
- input.value = '';
+ if (revision === sentAt) input.value = '';
api.append({ name: options.name, text: text, self: true });
})
.catch(() => say(SEND_FAILED))
diff --git a/test/browser/chat.test.mjs b/test/browser/chat.test.mjs
index 3372d1c..19113fd 100644
--- a/test/browser/chat.test.mjs
+++ b/test/browser/chat.test.mjs
@@ -610,6 +610,75 @@ test('a trimmed log does not slide the text a scrolled-up reader is looking at',
assert.equal(p.log.scrollTop, 160);
});
+// --- the draft, while a send is in flight (Codex S4-3) ---
+//
+// The box stays editable while a message is on its way, because a chat
+// where you cannot type for the length of a round trip is a chat that
+// feels broken. That makes the acceptance path a race: `input.value =
+// ''` clears whatever is in the box *then*, which is not necessarily
+// the text that was sent. So the draft carries a revision, every edit
+// bumps it, and a send only clears a box that has not been touched
+// since it left.
+
+test('a draft typed during a send survives the send being accepted', async () => {
+ const p = panel();
+ let release;
+ const held = spy(() => new Promise((resolve) => { release = resolve; }));
+ mount(p.root, { name: 'Ann', send: held.send });
+ p.input.value = 'first message';
+ fire(p.form, 'submit');
+ await settle();
+ assert.deepEqual(held.calls, ['first message']);
+
+ // Typing while the first one is still in the air.
+ p.input.value = 'second draft';
+ fire(p.input, 'input');
+
+ release(true);
+ await settle();
+ assert.equal(p.input.value, 'second draft', 'the accepted send ate a draft it never sent');
+ assert.equal(p.log.childElementCount, 1, 'the message that was accepted is still echoed');
+ assert.equal(partOf(p.log.firstElementChild, 'chat-text').textContent, 'first message');
+ assert.equal(p.button.disabled, false, 'and the newer draft can be sent');
+});
+
+test('a draft typed during a send survives the send being refused', async () => {
+ const p = panel();
+ let release;
+ const held = spy(() => new Promise((resolve) => { release = resolve; }));
+ mount(p.root, { name: 'Ann', send: held.send });
+ p.input.value = 'first message';
+ fire(p.form, 'submit');
+ await settle();
+
+ p.input.value = 'second draft';
+ fire(p.input, 'input');
+
+ release(false);
+ await settle();
+ assert.equal(p.input.value, 'second draft', 'a refusal wrote over a later edit');
+ assert.equal(p.log.childElementCount, 0);
+});
+
+test('a send nobody typed over still clears the box', async () => {
+ // The other half of the same rule: the revision only holds the box
+ // when there is really something newer in it. An untouched box is
+ // cleared exactly as it always was, twice over, so the counter cannot
+ // quietly stop clearing after the first message.
+ const p = panel();
+ const ok = spy(true);
+ mount(p.root, { name: 'Ann', send: ok.send });
+ for (const text of ['one', 'two']) {
+ p.input.value = text;
+ fire(p.input, 'input');
+ fire(p.form, 'submit');
+ await settle();
+ assert.equal(p.input.value, '', 'an accepted message leaves the box (' + text + ')');
+ }
+ assert.deepEqual(ok.calls, ['one', 'two']);
+ assert.equal(p.log.childElementCount, 2);
+});
+
test('a panel that is not there, or not complete, returns a working no-op', async () => {
for (const root of [null, undefined, fakeDoc().createElement('section')]) {
const ok = spy(true);