mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
After a session's first load per page, a tab switch fetched a 1 MiB tail of the server's byte stream. For a fullscreen claude pane (alternate screen, tmux `history_size` 0) that tail is old repaints of one frame, and an idle tab parsed it twice (the cached copy, then the fresh one). Every terminal response now records `paneHistoryLines` per session, and `selectSession` takes `full=1` whenever the last report was 0. For such a pane that capture is the visible frame, a few KB, which is also what a page load already shows. A response without the field forgets the entry, so unknown never counts as empty; a pane that starts keeping history goes back to the tail on the next switch. Shells and the refresh path are unchanged. Measured with real prod payloads through the real client in chromium: median switch 1.26-1.66 s -> 0.10-0.19 s, parse ~1.1-1.5 s -> 2-3 ms. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
222 lines
9.7 KiB
TypeScript
222 lines
9.7 KiB
TypeScript
// Port: none (pure helpers from constants.js in a vm context).
|
|
//
|
|
// Issue #258: terminal history is split across browser scrollback, the server
|
|
// byte buffer and tmux, and the only signal the user got was a grey line written
|
|
// INTO the terminal saying "earlier output truncated for performance". That line
|
|
// scrolls away with the output it describes, cannot be acted on, and says the
|
|
// same thing whether the rest is one click away or gone forever.
|
|
//
|
|
// computeHistoryTruncationNotice() is the pure core of the replacement banner.
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
|
|
|
function loadHelpers() {
|
|
const context = vm.createContext({ console, window: {}, document: {}, navigator: { userAgent: 'test' } });
|
|
vm.runInContext(
|
|
`${readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8')}
|
|
;globalThis.__helpers = { formatHistoryBytes, computeHistoryTruncationNotice };`,
|
|
context,
|
|
{ filename: 'constants.js' }
|
|
);
|
|
return (context as any).__helpers as {
|
|
formatHistoryBytes: (n: number) => string;
|
|
computeHistoryTruncationNotice: (s: Record<string, unknown>) => {
|
|
visible: boolean;
|
|
message: string;
|
|
canLoadMore: boolean;
|
|
};
|
|
};
|
|
}
|
|
|
|
describe('formatHistoryBytes', () => {
|
|
const { formatHistoryBytes } = loadHelpers();
|
|
|
|
it('reports sub-KB amounts as a range, not a byte count', () => {
|
|
expect(formatHistoryBytes(400)).toBe('less than 1 KB');
|
|
expect(formatHistoryBytes(0)).toBe('less than 1 KB');
|
|
});
|
|
|
|
it('scales to KB and MB', () => {
|
|
expect(formatHistoryBytes(2048)).toBe('2 KB');
|
|
expect(formatHistoryBytes(3 * 1024 * 1024)).toBe('3.0 MB');
|
|
});
|
|
|
|
it('never prints "1024 KB" for a tail cut back to a line boundary just under 1 MiB', () => {
|
|
expect(formatHistoryBytes(1048351)).toBe('1.0 MB');
|
|
expect(formatHistoryBytes(1023 * 1024)).toBe('1023 KB');
|
|
});
|
|
|
|
it('survives junk input rather than printing NaN into the UI', () => {
|
|
expect(formatHistoryBytes(-5)).toBe('less than 1 KB');
|
|
expect(formatHistoryBytes(NaN as unknown as number)).toBe('less than 1 KB');
|
|
expect(formatHistoryBytes(undefined as unknown as number)).toBe('less than 1 KB');
|
|
});
|
|
});
|
|
|
|
describe('computeHistoryTruncationNotice (issue #258)', () => {
|
|
const { computeHistoryTruncationNotice } = loadHelpers();
|
|
|
|
it('stays hidden when the replay was complete', () => {
|
|
const notice = computeHistoryTruncationNotice({ truncated: false, fullSize: 100, retainedBytes: 100 });
|
|
expect(notice.visible).toBe(false);
|
|
expect(notice.canLoadMore).toBe(false);
|
|
});
|
|
|
|
it('offers to load more after an intentional tail replay', () => {
|
|
const notice = computeHistoryTruncationNotice({
|
|
truncated: true,
|
|
reason: 'tail',
|
|
source: 'history',
|
|
fullSize: 5 * 1024 * 1024,
|
|
retainedBytes: 1024 * 1024,
|
|
});
|
|
expect(notice.visible).toBe(true);
|
|
expect(notice.canLoadMore).toBe(true);
|
|
expect(notice.message).toContain('1.0 MB');
|
|
expect(notice.message).toContain('more may still be retained');
|
|
});
|
|
|
|
it('promises nothing more once the FULL capture itself hit the ceiling', () => {
|
|
// This is the case the old boolean could not express: a full-history pull
|
|
// that was still capped means tmux has already given everything it has.
|
|
const notice = computeHistoryTruncationNotice({
|
|
truncated: true,
|
|
reason: 'capped',
|
|
source: 'mux-full-history',
|
|
fullSize: 40 * 1024 * 1024,
|
|
retainedBytes: 2 * 1024 * 1024,
|
|
});
|
|
expect(notice.visible).toBe(true);
|
|
expect(notice.canLoadMore).toBe(false);
|
|
expect(notice.message).toContain('cannot be recovered');
|
|
});
|
|
|
|
it('reports exhaustion when a full pull was refused as a downgrade', () => {
|
|
// _replayWouldShrinkBuffer refused: the browser holds MORE than tmux can
|
|
// return (a repaint-mode pane keeps no history), so offering "load more"
|
|
// would be offering to destroy history.
|
|
const notice = computeHistoryTruncationNotice({
|
|
truncated: true,
|
|
reason: 'tail',
|
|
source: 'history',
|
|
fullSize: 900000,
|
|
retainedBytes: 500000,
|
|
exhausted: true,
|
|
});
|
|
expect(notice.visible).toBe(true);
|
|
expect(notice.canLoadMore).toBe(false);
|
|
expect(notice.message).toContain('no longer kept');
|
|
});
|
|
|
|
it('lets exhaustion outrank a would-be recoverable state', () => {
|
|
const recoverable = { truncated: true, reason: 'tail', source: 'history', fullSize: 900, retainedBytes: 100 };
|
|
expect(computeHistoryTruncationNotice(recoverable).canLoadMore).toBe(true);
|
|
expect(computeHistoryTruncationNotice({ ...recoverable, exhausted: true }).canLoadMore).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('computeHistoryTruncationNotice: what a pull can really return (paneHistoryLines)', () => {
|
|
const { computeHistoryTruncationNotice } = loadHelpers();
|
|
// The tab-switch tail of a fullscreen claude pane, as measured on prod: the
|
|
// server cut a 5.8 MB byte stream to 1 MB, and tmux held 0 scrollback rows.
|
|
const fullscreenTail = {
|
|
truncated: true,
|
|
reason: 'tail',
|
|
source: 'mux-visible',
|
|
fullSize: 6158853,
|
|
retainedBytes: 1048351,
|
|
};
|
|
|
|
it('says nothing for a pane that keeps no scrollback, however much the byte stream lost', () => {
|
|
// The dropped bytes were old repaints of one frame, and `full=1` returns
|
|
// only the visible frame for such a pane, so the button could only ever end
|
|
// in the downgrade refusal. That is the banner that showed on every switch.
|
|
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 0 });
|
|
expect(notice).toEqual({ visible: false, message: '', canLoadMore: false });
|
|
});
|
|
|
|
it('stays silent for such a pane in the exhausted and at-ceiling states too', () => {
|
|
expect(computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 0, exhausted: true }).visible).toBe(
|
|
false
|
|
);
|
|
expect(
|
|
computeHistoryTruncationNotice({
|
|
...fullscreenTail,
|
|
source: 'mux-full-history',
|
|
reason: 'capped',
|
|
paneHistoryLines: 0,
|
|
}).visible
|
|
).toBe(false);
|
|
});
|
|
|
|
it('names the scrollback lines a pull can load instead of the byte gap', () => {
|
|
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, source: 'history', paneHistoryLines: 48210 });
|
|
expect(notice.visible).toBe(true);
|
|
expect(notice.canLoadMore).toBe(true);
|
|
expect(notice.message).toBe(
|
|
'Showing the most recent 1.0 MB of this session. 48,210 lines of scrollback are retained.'
|
|
);
|
|
expect(notice.message).not.toContain('4.9 MB');
|
|
});
|
|
|
|
it('uses the singular for one line', () => {
|
|
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 1 });
|
|
expect(notice.message).toContain('1 line of scrollback is retained.');
|
|
});
|
|
|
|
it('keeps the byte wording when the server did not report the pane (older server, byte-history fallback)', () => {
|
|
for (const paneHistoryLines of [undefined, null, NaN]) {
|
|
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines });
|
|
expect(notice.visible).toBe(true);
|
|
expect(notice.canLoadMore).toBe(true);
|
|
expect(notice.message).toContain('more may still be retained');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('the in-terminal truncation line is gone (static guard)', () => {
|
|
it('no longer writes the notice into terminal output', () => {
|
|
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
|
// The whole point of #258 is that this notice is no longer part of the
|
|
// scrollback it describes.
|
|
expect(app).not.toContain('earlier output truncated for performance');
|
|
});
|
|
|
|
it('loads a bounded shell tail first and keeps unbounded full history user-triggered', () => {
|
|
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
|
// A shell never takes the full capture on a tab switch. A TUI takes it on its
|
|
// first select per page, and on every select while its pane keeps no tmux
|
|
// scrollback (behaviour pinned in fullscreen-tab-switch-capture.browser.test.ts).
|
|
expect(app).toContain(
|
|
"session?.mode !== 'shell' && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId))"
|
|
);
|
|
expect(app).toContain('const paneKeepsNoHistory = this._paneHistoryLines?.get(sessionId) === 0;');
|
|
expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'");
|
|
expect(app).toContain('`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`');
|
|
// Every terminal capture now goes through _fetchTerminalCapture, which adds
|
|
// an abort deadline (a `?full=1` body can be megabytes and used to hang
|
|
// indefinitely on a stalled mobile link). The URL and the full-vs-tail
|
|
// decision this guard exists to pin are unchanged.
|
|
expect(app).toContain(': `/api/sessions/${sessionId}/terminal?full=1`,\n { full: true }');
|
|
expect(app).toContain("if (this.sessions.get(sessionId)?.mode !== 'shell')");
|
|
expect(app).toContain("if (session?.mode === 'shell')");
|
|
// A shell scroll gesture pulls a BOUNDED window of full history; only the
|
|
// button pulls all of it (behaviour pinned in shell-scroll-history-pull.test.ts).
|
|
expect(app).toContain("const boundedShellPull = !force && session?.mode === 'shell';");
|
|
expect(app).toContain('`/api/sessions/${sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`');
|
|
expect(app).toContain("trigger: force ? 'full-history-button' : 'full-history-scroll'");
|
|
});
|
|
|
|
it('renders the banner through textContent, never innerHTML', () => {
|
|
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
|
const start = app.indexOf('_renderHistoryTruncationBanner() {');
|
|
expect(start).toBeGreaterThan(-1);
|
|
const body = app.slice(start, app.indexOf('\n _shouldFocusTerminalForTabSwitch', start));
|
|
expect(body).not.toContain('innerHTML');
|
|
});
|
|
});
|