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/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index e621e14b..8d1f47e3 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -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. diff --git a/src/web/public/app.js b/src/web/public/app.js index 3765bac5..8e51581e 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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=` 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. diff --git a/test/connection-indicator.test.ts b/test/connection-indicator.test.ts index 63477c2c..24d4f4bd 100644 --- a/test/connection-indicator.test.ts +++ b/test/connection-indicator.test.ts @@ -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'); }); }); + 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; }