fix(web): hold the WS pill's queued-bytes suffix until a backlog persists

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.
This commit is contained in:
Codeman maintainer committed 2026-10-11 11:37:09 +08:00
1 parent 3d59e59e37
commit 764910baca
3 files changed
+118 -5

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.
+43 -3
View File
@@ -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=<id>` 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.
+70 -2
View File
@@ -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');
});
});