mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
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:
commit
9fc7781442
5 files changed
+127
-9
No files matched your search
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user