From 764910baca7738b95ed4ff95bdeb6284742d440f Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 11 Oct 2026 11:36:46 +0800 Subject: [PATCH 1/6] fix(web): hold the WS pill's queued-bytes suffix until a backlog persists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A paste, an IME commit or a fast word sits above the 4-byte hint threshold for a few milliseconds until its ACK lands, and each one widened the header pill (`WS · 12B queued`) and moved the gauges beside it. The suffix now appears only after the backlog has stayed above the threshold for 1.2s; a drain forgets the burst and a timer re-renders when the hold elapses. --- .changeset/4cecc61b.md | 5 +++ src/web/public/app.js | 46 ++++++++++++++++++-- test/connection-indicator.test.ts | 72 ++++++++++++++++++++++++++++++- 3 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 .changeset/4cecc61b.md diff --git a/.changeset/4cecc61b.md b/.changeset/4cecc61b.md new file mode 100644 index 00000000..ee77bab1 --- /dev/null +++ b/.changeset/4cecc61b.md @@ -0,0 +1,5 @@ +--- +"aicodeman": patch +--- + +Header connection pill: the " · NB queued" suffix on `WS` now appears only after a backlog has stayed above the threshold for over a second, instead of flashing on every paste, IME commit or fast burst that ACKs a moment later. diff --git a/src/web/public/app.js b/src/web/public/app.js index dda35644..f15906b2 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -687,6 +687,12 @@ const DEFAULT_SHORTCUTS = [ * setInterval fire on every frame, and constants.js is cached independently. */ const SIDEBAR_RICH_CLOCK_MS = 20000; +// Header connection pill: a queue above CONNECTION_BACKLOG_HINT_BYTES is only worth a " · NB queued" +// suffix once it has STAYED above it for CONNECTION_BACKLOG_HOLD_MS. A paste, an IME commit or a fast +// word sits above the threshold for a few milliseconds until its ACK lands, and surfacing that made the +// pill grow to the right and shrink back on every such burst. +const CONNECTION_BACKLOG_HINT_BYTES = 4; +const CONNECTION_BACKLOG_HOLD_MS = 1200; /** * How long a `#session=` link waits for the session list to name its id @@ -4291,9 +4297,15 @@ class CodemanApp { const hasQueue = count > 0; // Only surface a backlog once it's more than a few bytes. A single keystroke // (1B) ACKs in milliseconds, so without this the label flickered "sending 1B" - // on every key press. Above this threshold means input is genuinely backing up. - const BACKLOG_HINT_BYTES = 4; - const showBacklog = totalBytes > BACKLOG_HINT_BYTES; + // on every key press. Above this threshold means input is genuinely backing up, + // and only once it has stayed there (see CONNECTION_BACKLOG_HOLD_MS): a burst + // that drains in a moment must not widen the pill. `_backlogOverSince` is kept + // by _updateConnectionIndicator; `undefined` (never tracked, as when this pure + // compute is called directly) means no hold. + const since = this._backlogOverSince; + const heldLongEnough = + since === undefined || (since !== null && Date.now() - since >= CONNECTION_BACKLOG_HOLD_MS); + const showBacklog = totalBytes > CONNECTION_BACKLOG_HINT_BYTES && heldLongEnough; const formatBytes = (b) => (b < 1024 ? `${b}B` : `${(b / 1024).toFixed(1)}KB`); const queuedSuffix = showBacklog ? ` · ${formatBytes(totalBytes)} queued` : ''; @@ -4409,6 +4421,34 @@ class CodemanApp { const text = this.$('connectionText'); if (!indicator || !dot || !text) return; + // Track how long the queue has stayed above the hint threshold, and make sure + // the pill is re-rendered when the hold elapses even if no keystroke or ACK + // arrives to trigger it. + // ⚠️ This runs before _drainSession() on every keystroke, so it must never throw: a throw here + // once skipped immediate delivery and made typing lag (the ReferenceError regression above). + let pendingBytes = 0; + try { + pendingBytes = this._pendingBytes().bytes; + } catch { + /* no queue to read: treat as drained */ + } + if (pendingBytes > CONNECTION_BACKLOG_HINT_BYTES) { + if (this._backlogOverSince == null) this._backlogOverSince = Date.now(); + if (!this._backlogHoldTimer && Date.now() - this._backlogOverSince < CONNECTION_BACKLOG_HOLD_MS) { + const wait = CONNECTION_BACKLOG_HOLD_MS - (Date.now() - this._backlogOverSince) + 25; + this._backlogHoldTimer = setTimeout(() => { + this._backlogHoldTimer = null; + this._updateConnectionIndicator(); + }, wait); + } + } else { + this._backlogOverSince = null; + if (this._backlogHoldTimer) { + clearTimeout(this._backlogHoldTimer); + this._backlogHoldTimer = null; + } + } + // Called on EVERY keystroke (_reliableSend) and EVERY ACK (_ackDelivery). // During fast typing the rendered tuple is usually identical, so skip the DOM // writes when nothing changed (COD-136) — the compute above is DOM-free. diff --git a/test/connection-indicator.test.ts b/test/connection-indicator.test.ts index 63477c2c..09acc8ec 100644 --- a/test/connection-indicator.test.ts +++ b/test/connection-indicator.test.ts @@ -158,10 +158,60 @@ describe('connection indicator — keystroke backlog threshold', () => { expect(els.connectionText.textContent).toBe('WS'); }); - it('annotates a genuine backlog (>4B) with a queued byte count', () => { - const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 }); + it('annotates a genuine backlog (>4B) with a queued byte count once it has persisted', () => { + const { app, els } = makeApp({ + activeSessionId: 's1', + _wsState: 'connected', + queuedBytes: 40, + // The backlog has been above the threshold for well over the hold. + _backlogOverSince: Date.now() - 5000, + } as never); app._updateConnectionIndicator(); expect(els.connectionText.textContent).toMatch(/^WS · 40B queued$/); + app._pendingDeliveries.clear(); + app._updateConnectionIndicator(); // drains: clears the hold timer + }); +}); + +// A paste, an IME commit or a fast word is a few dozen bytes for a few milliseconds until its ACK +// lands. Showing " · NB queued" for that made the "WS" pill widen and shrink on every burst. +describe('connection indicator — a brief burst does not widen the pill', () => { + it('keeps the plain "WS" while a >4B burst is still inside the hold window', () => { + const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 }); + app._updateConnectionIndicator(); + expect(els.connectionText.textContent).toBe('WS'); + app._pendingDeliveries.clear(); + app._updateConnectionIndicator(); + expect(els.connectionText.textContent).toBe('WS'); + }); + + it('forgets the burst when it drains, so a later one starts a fresh hold', () => { + const { app } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 }); + app._updateConnectionIndicator(); + expect((app as unknown as { _backlogOverSince: number | null })._backlogOverSince).not.toBeNull(); + app._pendingDeliveries.clear(); + app._updateConnectionIndicator(); + expect((app as unknown as { _backlogOverSince: number | null })._backlogOverSince).toBeNull(); + expect((app as unknown as { _backlogHoldTimer: unknown })._backlogHoldTimer).toBeNull(); + }); + + it('schedules the re-render for when the hold elapses, with no keystroke or ACK needed', () => { + const { app } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 }); + app._updateConnectionIndicator(); + expect((app as unknown as { _backlogHoldTimer: unknown })._backlogHoldTimer).not.toBeNull(); + app._pendingDeliveries.clear(); + app._updateConnectionIndicator(); // cancels it + }); + + it('shows the count once the same backlog has stayed up past the hold', () => { + const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 }); + app._updateConnectionIndicator(); + expect(els.connectionText.textContent).toBe('WS'); + (app as unknown as { _backlogOverSince: number })._backlogOverSince = Date.now() - 1500; + app._updateConnectionIndicator(); + expect(els.connectionText.textContent).toBe('WS · 40B queued'); + app._pendingDeliveries.clear(); + app._updateConnectionIndicator(); }); }); @@ -458,3 +508,21 @@ describe('_updateConnectionIndicator — COD-136 unchanged-skip', () => { expect(els.connectionText.textContent).toBe('WS'); }); }); + +describe('connection indicator — the backlog tracking never throws on the input path', () => { + it('still renders when the queue cannot be read', () => { + const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected' }); + (app as unknown as { _pendingBytes: () => never })._pendingBytes = () => { + throw new Error('no queue'); + }; + // The compute reads the queue too, so stub it as the real hot path would see a healthy one. + (app as unknown as { _computeConnectionDescriptor: () => unknown })._computeConnectionDescriptor = () => ({ + display: 'flex', + dotClass: 'connection-dot connected', + text: 'WS', + title: 'Terminal connected over WebSocket', + }); + expect(() => app._updateConnectionIndicator()).not.toThrow(); + expect(els.connectionText.textContent).toBe('WS'); + }); +}); From d8cafc6d0d02a4f2a12be132c5a23b88674e9e8f Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 11 Oct 2026 12:53:42 +0800 Subject: [PATCH 2/6] docs(dashboard): describe the header transport readout and when it shows queued bytes --- docs/wiki/The-Dashboard.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index 963b2fa2..7ed5ad20 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -202,6 +202,13 @@ Both wait about 2.5 seconds before appearing, so a deploy that restarts the serv flash a warning at you every time. If the browser reports itself offline, the grace period is skipped. +**The transport readout.** With a session open, the header shows how its terminal is +connected: `WS` (WebSocket, the normal case), `WS…` (connecting or reconnecting) or `HTTP` +(the WebSocket is unavailable and input goes over HTTP). If typed or pasted input is backing +up faster than it is acknowledged, the readout grows a suffix such as `WS · 1.2KB queued`. +That suffix appears only after the backlog has stayed up for about a second, so a paste or a +fast burst that clears at once does not make the pill widen and shrink. + There is also a watchdog for the case where the connection stops delivering without erroring. If the server's heartbeat stops arriving, Codeman reconnects on its own rather than sitting on a green dot showing frozen data. From 67ca0954dbb01d0c80e80326bfb321853a45aca6 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 08:08:46 +0200 Subject: [PATCH 3/6] fix(#647): read the pending queue once per indicator update The try/catch around _pendingBytes() could not keep _updateConnectionIndicator() from throwing, because _computeConnectionDescriptor() read the queue again, unguarded, right after; its test only passed because it stubbed the compute out. The update now reads the queue once and hands the totals to the compute (which still reads a fresh queue when called on its own), so the hot input path walks the queue once per keystroke and ACK instead of twice. The stubbed test is gone: the COD-133 combinatorial test already pins never-throw. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/app.js | 22 ++++++++++------------ test/connection-indicator.test.ts | 17 ----------------- 2 files changed, 10 insertions(+), 29 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index f15906b2..14a751b0 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -4287,13 +4287,14 @@ class CodemanApp { } } - // Pure render of the header connection indicator: reads only `this.*` state, + // Pure render of the header connection indicator: reads only `this.*` state (and + // the pending totals it is handed, which default to a fresh `_pendingBytes()`), // touches NO DOM. Returns the exact { display, dotClass, text, title } tuple the // writer applies. When hidden (display:'none') the other three are normalized to // '' so the cache compare in _updateConnectionIndicator() is well-defined. // Every branch/string here must stay byte-identical to what's rendered today. - _computeConnectionDescriptor() { - const { bytes: totalBytes, count } = this._pendingBytes(); + _computeConnectionDescriptor(pending = this._pendingBytes()) { + const { bytes: totalBytes, count } = pending; const hasQueue = count > 0; // Only surface a backlog once it's more than a few bytes. A single keystroke // (1B) ACKs in milliseconds, so without this the label flickered "sending 1B" @@ -4425,14 +4426,11 @@ class CodemanApp { // the pill is re-rendered when the hold elapses even if no keystroke or ACK // arrives to trigger it. // ⚠️ This runs before _drainSession() on every keystroke, so it must never throw: a throw here - // once skipped immediate delivery and made typing lag (the ReferenceError regression above). - let pendingBytes = 0; - try { - pendingBytes = this._pendingBytes().bytes; - } catch { - /* no queue to read: treat as drained */ - } - if (pendingBytes > CONNECTION_BACKLOG_HINT_BYTES) { + // once skipped immediate delivery and made typing lag (COD-133; the combinatorial never-throws + // test in test/connection-indicator.test.ts pins it). The queue is walked ONCE per call and the + // same totals are handed to the compute below, since this is the hot input path. + const pending = this._pendingBytes(); + if (pending.bytes > CONNECTION_BACKLOG_HINT_BYTES) { if (this._backlogOverSince == null) this._backlogOverSince = Date.now(); if (!this._backlogHoldTimer && Date.now() - this._backlogOverSince < CONNECTION_BACKLOG_HOLD_MS) { const wait = CONNECTION_BACKLOG_HOLD_MS - (Date.now() - this._backlogOverSince) + 25; @@ -4452,7 +4450,7 @@ class CodemanApp { // Called on EVERY keystroke (_reliableSend) and EVERY ACK (_ackDelivery). // During fast typing the rendered tuple is usually identical, so skip the DOM // writes when nothing changed (COD-136) — the compute above is DOM-free. - const next = this._computeConnectionDescriptor(); + const next = this._computeConnectionDescriptor(pending); const prev = this._lastIndicatorDescriptor; // The tile's value word is written in the UI language, so a language // switch counts as a change too. diff --git a/test/connection-indicator.test.ts b/test/connection-indicator.test.ts index 09acc8ec..929c1efe 100644 --- a/test/connection-indicator.test.ts +++ b/test/connection-indicator.test.ts @@ -509,20 +509,3 @@ describe('_updateConnectionIndicator — COD-136 unchanged-skip', () => { }); }); -describe('connection indicator — the backlog tracking never throws on the input path', () => { - it('still renders when the queue cannot be read', () => { - const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected' }); - (app as unknown as { _pendingBytes: () => never })._pendingBytes = () => { - throw new Error('no queue'); - }; - // The compute reads the queue too, so stub it as the real hot path would see a healthy one. - (app as unknown as { _computeConnectionDescriptor: () => unknown })._computeConnectionDescriptor = () => ({ - display: 'flex', - dotClass: 'connection-dot connected', - text: 'WS', - title: 'Terminal connected over WebSocket', - }); - expect(() => app._updateConnectionIndicator()).not.toThrow(); - expect(els.connectionText.textContent).toBe('WS'); - }); -}); From 9a57cd4fc41f04c8df1e6cdc4ee0e71898506124 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 08:08:46 +0200 Subject: [PATCH 4/6] fix(#647): pin the hold timer's own re-render The timer test only checked that a handle existed, and the other hold tests re-rendered by hand, so deleting the re-render from the timer callback left every test green. A new test waits out the hold for real and asserts the suffix appears and the timer is spent with no further call. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/connection-indicator.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/connection-indicator.test.ts b/test/connection-indicator.test.ts index 929c1efe..24d4f4bd 100644 --- a/test/connection-indicator.test.ts +++ b/test/connection-indicator.test.ts @@ -203,6 +203,19 @@ describe('connection indicator — a brief burst does not widen the pill', () => app._updateConnectionIndicator(); // cancels it }); + it('shows the count by itself once the hold elapses, and the timer is spent', async () => { + // A real wait, on purpose: the vm context runs on its own Date and the real setTimeout, so this + // exercises the shipped timer callback rather than a manual re-render. + const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 }); + app._updateConnectionIndicator(); + expect(els.connectionText.textContent).toBe('WS'); + await new Promise((r) => setTimeout(r, 1400)); + expect(els.connectionText.textContent).toBe('WS · 40B queued'); + expect((app as unknown as { _backlogHoldTimer: unknown })._backlogHoldTimer).toBeNull(); + app._pendingDeliveries.clear(); + app._updateConnectionIndicator(); + }); + it('shows the count once the same backlog has stayed up past the hold', () => { const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 }); app._updateConnectionIndicator(); From 35a0000f136c20464ff128b41c23d0ce02be522f Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 08:08:46 +0200 Subject: [PATCH 5/6] fix(#647): say which header styles show the queued suffix The suffix is part of the pill's text line, which the Tiles style hides (it shows WS live) and phones hide too, so the page promised a suffix some users never see. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/wiki/The-Dashboard.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index 7ed5ad20..c7956f69 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -207,7 +207,9 @@ connected: `WS` (WebSocket, the normal case), `WS…` (connecting or reconnectin (the WebSocket is unavailable and input goes over HTTP). If typed or pasted input is backing up faster than it is acknowledged, the readout grows a suffix such as `WS · 1.2KB queued`. That suffix appears only after the backlog has stayed up for about a second, so a paste or a -fast burst that clears at once does not make the pill widen and shrink. +fast burst that clears at once does not make the pill widen and shrink. (It is part of the +text line, so you see it in the Compact and As before header styles; Tiles hides that line and +shows `WS live`, and phones hide it too.) There is also a watchdog for the case where the connection stops delivering without erroring. If the server's heartbeat stops arriving, Codeman reconnects on its own rather From efe3151377aff4af57927538c5685275d640bd93 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 08:11:44 +0200 Subject: [PATCH 6/6] fix(#647): give the header-stats test app the input queue the constructor creates Its prototype-only app had no _pendingDeliveries and only passed because the removed try/catch swallowed the read. The real constructor always creates the Map. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/header-stats-style.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/header-stats-style.test.ts b/test/header-stats-style.test.ts index 3c4c0a3e..41267cf0 100644 --- a/test/header-stats-style.test.ts +++ b/test/header-stats-style.test.ts @@ -69,6 +69,8 @@ beforeAll(async () => { function makeApp() { const app = Object.create(CodemanApp.prototype) as Record; app.$ = (id: string) => document.getElementById(id); + // The constructor always creates the input queue; _updateConnectionIndicator reads it. + app._pendingDeliveries = new Map(); return app; }