- date
- entry
- 043
- topic
- tooling
- rev
- —
A browser extension typing mixed Chinese and English: ERP and the spaces around it vanished
A 119-character reply came out 5 characters short, with "ERP" gone. Typing it in parts lost them again, and the box wouldn't clear. The extension's source shows how it sends characters, but I couldn't reproduce it locally, so now I compare character by character before sending.
I use the Claude in Chrome browser extension to let an agent answer questions on Threads from a tab where I'm logged in. Its type action means "type at the current focus". The tool reporting that it finished typing doesn't by itself mean anyone has checked the text in the box.
The reply at 7:30 pm on October 13 was 119 characters of Chinese with "我們做電商與 ERP 串接" ("we do e-commerce and ERP integration") in the middle. Reading the box back after typing, it was 5 characters short and "ERP" was gone (judging by a later attempt in parts, what went missing was "ERP" plus the spaces on either side). Typing it in three pieces, still gone. Trying to clear the box and start over, select-all and delete had no effect, and the box ended up holding three segments. This note is about those few minutes and the approach I switched to. In order: the record, what type actually sends, the local reproduction, and how I check before sending.
The record
From that session's tool-call log (times +08:00, taken from when each call started; lengths are the innerText length read back each time, without trim() in the first few checks and with it later):
| Time | Action | What the box held |
|---|---|---|
| 19:28:58 | Previous reply: click the box, type the whole text | Not the intended result: the box still held an earlier incomplete draft of this same reply (109 chars, no "Excel") |
| 19:29:13 | Click, cmd+a, Delete, type again | 116 chars, "Excel" present; submitted, page showed it awaiting the author's approval |
| 19:30:17 | This reply: click, cmd+a, Delete, type 119 chars | 114 chars, no "ERP" |
| 19:30:30 | cmd+a, Delete, type in three parts: "…電商與 ", "ERP", " 串接…" | 228 chars (114 + 114): ending "…電商與串接…"; the old text apparently not cleared, the new one again missing " ERP " |
| 19:30:40 | JavaScript: execCommand('selectAll'), delete, then insertText the whole text | 347 chars (114 + 114 + 119): this time a complete segment was appended |
| 19:30:48 | Select the box's contents with a Range, delete; don't insert unless empty | 347 chars, unchanged |
| 19:30:58 | Click, cmd+End, Backspace 360 times | 347 chars, unchanged |
| 19:31:10 | Click on the text with the tool's coordinate click, cmd+a, Delete | 0 chars |
| 19:31:15 | execCommand('insertText') the whole text, compare | 119/119, identical; submitted at 19:31:23 |
Reading the reply back from the page after sending, it had the complete "電商與 ERP 串接". The next two replies that same evening went straight to insert-and-compare: 162/162 and 125/125.
Two things in the table are worth separating. One is dropped characters: the first time, 114 = 119 − 5 and the readback had no "ERP" (the full string wasn't saved that time); in the attempt in parts, the ending shows the first part's trailing space, all of "ERP" and the third part's leading space didn't get in. The other is the box that wouldn't clear: after the 19:30:48 and 19:30:58 attempts the readback was still 347; the 19:30:40 attempt had no separate readback after deleting, only the overall change from 228 to 347. Only clicking on the text and then selecting all and deleting emptied it. The 19:28:58 reply had already been a warning: without reading the box back before sending, an incomplete draft could have gone out.
What type actually sends
The extension is JavaScript installed locally, so it can be read. What I read afterwards was the locally installed 1.0.100 (the bundle under assets/; the record doesn't show whether that day's version was the same). type processes the text one character at a time, simplified:
async type(tab, text) {
const r = [];
for (const ch of text) {
const key = this.getKeyCode(ch === "\n" || ch === "\r" ? "Enter" : ch);
if (key) { // mapped: letters, digits, space, Enter, some ASCII punctuation
const shift = this.requiresShift(ch) ? 8 : 0;
r.push(this.keyDown(tab, key, shift), this.keyUp(tab, key, shift));
} else { // unmapped: Chinese, full-width punctuation such as ","…
r.push(this.insertText(tab, ch));
}
}
await Promise.all(r);
}
(Variable names rewritten and some details omitted.) Mapped characters go through the Chrome DevTools Protocol's Input.dispatchKeyEvent; in this example the space and ERP are sent as keyDown and keyUp. Every unmapped character gets its own Input.insertText, the CDP command for emulating an input method or emoji keyboard. Each command is issued as soon as it's called, its promise pushed into an array and only awaited at the end with Promise.all; none waits for the previous one's response.
So in its hands "我們做電商與 ERP 串接" is a run of insertText calls, then key events for five characters (space, E, R, P, space), then more insertText. The first readback was five characters short and lacked ERP; the attempt in parts showed that what was missing was " ERP ", the five key-event characters in the middle. This sending pattern is inferred from the source I read afterwards; there's no per-command CDP trace from that day.
I couldn't reproduce it locally
On a local test page in headless Chrome 155, a simplified CDP script followed the same main routing to send "我們做電商與 ERP 串接,需要可以聊聊" (a string with Latin letters, spaces, Chinese characters and a full-width comma), 20 runs per combination, checking whether innerText matched exactly:
| Editor | Issued without waiting (Promise.all) | One at a time, awaiting each |
|---|---|---|
Plain contenteditable | 20/20 | 20/20 |
A "state editor" I wrote: cancels beforeinput, keeps its own text, re-renders | 20/20 | 20/20 |
| Lexical 0.52.0 (plain-text plugin) | 20/20 | 20/20 |
All 120 runs matched exactly. In other words, under these test conditions, mixing key events and insertText without waiting for responses didn't lose characters. What editor Threads uses and how it handles those two kinds of events, I don't know; that day it was a tab in a normal window while my reproduction was headless; and the extension has code whose name suggests it emulates tab focus (__cdpFocusEmulation). Which of those differences dropped the characters, I can't tell. The same goes for the box that wouldn't clear; I didn't find the cause.
What I can say: the same way of sending lost characters twice in that box, and a simplified local script reproducing the main routing never lost any in 120 runs across three editors. So I no longer treat "finished typing" as "the box holds what I meant".
One insertion instead, knowing what it skips
The 19:31:15 attempt used document.execCommand('insertText', false, wholeText): one insertion, no per-character keys. MDN marks execCommand as deprecated, but Chrome still supports it; the ProseMirror note also tested it locally, though it settled on a paste event.
There's one difference to know first. MDN says modifications performed by execCommand() "may or may not trigger beforeinput and input events, depending on the browser and configuration". I measured once on a plain contenteditable in Chrome 155.0.8059.41: inserting "ABC" with execCommand('insertText') recorded only input, no beforeinput; CDP's Input.insertText sending "中" and a key event sending "E" each recorded one beforeinput (that check listened only for beforeinput). The "state editor" in the table above listens only to beforeinput, so after an execCommand insertion its innerText on screen matched the text exactly while the text it stored was an empty string. If a real editor behaved that way and submitted its stored copy, what went out could differ from what's on screen.
In the same test, Lexical's own state matched the screen, and the Threads reply read back complete after sending, so at least that day that box accepted this kind of insertion. The check therefore has two halves: compare the screen before sending, and read back what was actually posted afterwards.
The check before sending
The next replies that same evening used the approach "confirm it's empty, insert once, compare the whole text". As a function (tested once on each of the three local editors, starting from a box that already held an old draft; all three returned OK 20/20, meaning actual and expected were both 20 characters). It compares the on-screen text with surrounding whitespace trimmed, and the waits are fixed values, not a guarantee the editor has finished processing:
async function setComposer(el, text) {
el.focus();
const r = document.createRange(); r.selectNodeContents(el);
const s = getSelection(); s.removeAllRanges(); s.addRange(r);
document.execCommand("delete");
await new Promise(res => setTimeout(res, 200));
const left = el.innerText.trim();
if (left !== "") return "NOT EMPTY (" + left.length + " chars left), nothing inserted";
document.execCommand("insertText", false, text);
await new Promise(res => setTimeout(res, 300));
const got = el.innerText.trim();
return got === text ? "OK " + got.length + "/" + text.length
: "MISMATCH " + got.length + "/" + text.length;
}
- If it won't clear, stop. At 19:30:48, "don't insert unless empty" is what blocked another insertion. When clearing fails, use the tool's coordinate click on the text, then select all and delete; that's what worked that day.
- Compare the whole text, not a fragment. At 19:30:40 the last 22 characters printed were exactly right; only the full comparison (
same=false, 347 characters) showed two more segments stacked in front. - Read it back after sending. Find the reply just posted on the page and confirm it contains the full text; a matching screen doesn't mean the editor's own state matches, so compare that too when you can read it.
If you remember one thing
Automated typing only counts once you've read the text back and compared it character by character. In the reply at the top, by the time type reported "Typed", "ERP" was already gone from the box. I still don't know why it dropped, but the comparison kept it from being sent.
Revision history
- First published