Merge pull request #647: fix(web): hold the WS pill's queued-bytes suffix until a backlog persists

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 08:16:43 +02:00
commit 9fc7781442
5 files changed
+127 -9

No files matched your search

+5
View File
@@ -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.
+9
View File
@@ -202,6 +202,15 @@ 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. (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
than sitting on a green dot showing frozen data.
+45 -7
View File
@@ -699,6 +699,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=<id>` link waits for the session list to name its id
@@ -4364,19 +4370,26 @@ 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"
// 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` : '';
@@ -4492,10 +4505,35 @@ 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 (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;
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.
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.
+66 -2
View File
@@ -158,10 +158,73 @@ 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 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();
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 +521,4 @@ describe('_updateConnectionIndicator — COD-136 unchanged-skip', () => {
expect(els.connectionText.textContent).toBe('WS');
});
});
+2
View File
@@ -69,6 +69,8 @@ beforeAll(async () => {
function makeApp() {
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
app.$ = (id: string) => document.getElementById(id);
// The constructor always creates the input queue; _updateConnectionIndicator reads it.
app._pendingDeliveries = new Map();
return app;
}