mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-11 01:39:41 +02:00
fix(tiles): treat the server's clear frame as a refresh, not a bare clear
The server sends {t:'c'} from one place only: a fresh Claude pane's first
prompt (Session.startInteractive), meaning "refresh after startup". The
primary pane answers it with a refetch and replay (_onSessionClearTerminal),
and stands aside while the grid is open, so the tile's own handling was the
only one that ran. That handling was a bare xterm clear(), which keeps only
the cursor's row and drops the banner, a resumed transcript and all
scrollback. An idle Claude never repaints static rows, so a Claude session
Run into the grid, or Attached in a tile, came up as a near-empty tile.
_onLiveClear() now calls _refreshBuffer(), the {t:'r'} path: single-flight,
coalesced into one trailing refresh behind a load already running (a shell
pull's held frames included), and paced by the grid's TileLoadQueue. The
queued {clear:true} entry and its branch in _pullHistory's flush are gone,
along with the _clearTerminal helper they used.
Tests: two unit tests pinned the bare clear (a clear frame queued in order
during a pull, and one applied at once before the capture); they are
replaced by tests that the frame coalesces behind the pull and refetches,
plus a socket-level {t:'c'} test, a coalescing test, and a grid test that
the frame waits its turn in the load queue.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -387,6 +387,55 @@ describe('TerminalTile reconnects after a transient drop', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the server's {t:'c'} frame refreshes the tile (a Claude pane's first prompt)", () => {
|
||||
// Session.startInteractive (session.ts) sends it once a fresh Claude pane
|
||||
// shows its prompt, meaning "refresh after startup"; the primary pane refetches
|
||||
// and replays (_onSessionClearTerminal). A tile used to run a bare xterm
|
||||
// clear(), which kept only the cursor's row: banner and transcript gone, and an
|
||||
// idle Claude never repaints them.
|
||||
it('refetches the capture and replays it, never a bare clear', async () => {
|
||||
const { ws, term } = await connectTile(makeApp());
|
||||
ws.open();
|
||||
fetchMock.mockClear();
|
||||
fetchMock.mockImplementation(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: { terminalBuffer: 'Claude Code banner\r\n❯ ' } }),
|
||||
}));
|
||||
|
||||
ws.receive({ t: 'o', d: 'banner painted live' });
|
||||
ws.receive({ t: 'c' });
|
||||
await settle();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('/api/sessions/s-tile/terminal?full=1');
|
||||
expect(term.writes.at(-1)).toBe('Claude Code banner\r\n❯ ');
|
||||
});
|
||||
|
||||
it('two clear frames during one refresh fetch once more, not twice', async () => {
|
||||
const { ws } = await connectTile(makeApp());
|
||||
ws.open();
|
||||
fetchMock.mockClear();
|
||||
let release!: () => void;
|
||||
fetchMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolveFetch) => {
|
||||
release = () =>
|
||||
resolveFetch({ ok: true, status: 200, json: async () => ({ data: { terminalBuffer: 'a' } }) });
|
||||
})
|
||||
);
|
||||
|
||||
ws.receive({ t: 'c' });
|
||||
ws.receive({ t: 'c' });
|
||||
ws.receive({ t: 'c' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
release();
|
||||
await settle();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TerminalTile stops for good on codes that cannot get better', () => {
|
||||
it.each([
|
||||
[4004, 'the session ended'],
|
||||
|
||||
Reference in New Issue
Block a user