mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +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:
1 parent
daf7330d6e
commit
e39a750749
5 files changed
+131
-32
No files matched your search
@@ -801,7 +801,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
|
||||
|
||||
### Split-pane sessions
|
||||
|
||||
**Split-pane sessions** (`showSplitButton`, header button, default OFF, per-device like `showFileViewerButton` — not in `SettingsUpdateSchema`, `displayKeys` in settings-ui.js): shows two live sessions side-by-side in one Codeman window. Pane A is the untouched, existing singleton terminal (`this.terminal`/`this._ws` in terminal-ui.js); Pane B is an independent `TerminalTile` (terminal-tile.js, constructed by the orchestration in terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket, whose `cid` is the tab's identity with a `:tile` suffix, so it can never supersede the primary pane's socket (the registry supersedes by cid per session). ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar (it does carry the primary pane's hollow-buffer wheel paging and desktop click report, through terminal-ui.js's own gates aimed at the tile, and its own keyCode-229 soft-keyboard controller from terminal-keycode229-recovery.js, the #441 next-keydown drain and #541's edit-based diff for Android autocorrect, since the 1180px width gate is reachable by a wide Android tablet; see terminal-tile.js's fileoverview) — since this is a desktop-only feature (a split view needs a wide viewport) and those features exist for mobile/touch input; `.btn-split` is hard-hidden below 1180px regardless of the setting by the `@media (max-width: 1179px)` rule in styles.css (mobile.css only carries a comment pointing at it: that file loads up to 1023px, so it cannot cover the 1024-1179px tablet range the feature also needs to stay off), and the per-device setting means turning it on at a desk can never sync it onto a phone in the first place. No persistence: closing the browser tab or reloading always returns to the normal single-pane view; there is no localStorage key for split state. ⚠️ Splitting a session against itself is disallowed (the picker excludes the active session), **as is splitting against a popped-out (detached) session** — `buildSplitPickerSessions()` excludes `detachedSessions` because a detached session's own window is already claiming its PTY size, and `MAX_WS_PER_SESSION` needs no change since Pane A/B are always two different sessions. ⚠️ Either pane's session ending (deleted locally or from another client) auto-collapses the split — Pane A's session ending promotes Pane B to the new single pane via `selectSession(id, { auto: true })` (an app-driven selection, so it must not spend the session's idle alert — see the Approvals Inbox note above), never by trying to hot-swap the lightweight `TerminalTile` object into the primary singleton state. ⚠️ Pane B refits on every window/sidebar/tab-rail resize via the SAME trailing-edge `ResizeObserver` callback that resizes Pane A (`throttledResize` in terminal-ui.js) — it only ever measured Pane A's own container, so without the explicit `this._forEachTile?.((tile) => tile.fit(), { grid: false })` call there (grid tiles are left out: the grid's own observer refits them on the same resize) Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket writes a `[disconnected, reconnecting…]` marker into Pane B and reconnects on the primary pane's backoff ladder (`CodemanWsReconnect` plus jitter; the attempt count resets only on a successful open), then refreshes the buffer to close the output gap. ⚠️ The open handler clears `_wsClosed`/`_markerOwed` BEFORE that refresh, or the refresh re-owes the marker and stamps it under a healthy pane. ⚠️ 4003/4004/4009/4010 stop the pane for good, report once through `onExit(code)` and write a marker saying why (`CodemanWsReconnect` alone would retry 4003). ⚠️ A replacement socket detaches the old one first and every handler ignores a socket that is no longer `this.ws`, so a late 4010 from a superseded socket cannot stop its successor; `destroy()` cancels a pending reconnect. Related but distinct: `detachSession()` already opens one session in a separate OS-level browser window (`isSoloWindow`) — that is prior art for "two sessions visible at once" but not for one window with a draggable in-page divider, which is what this feature adds. ⚠️ Pane B installs its own `attachCustomKeyEventHandler` gating the same app-level chords Pane A's own handler gates (command palette, Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter newline, smart-copy Ctrl+C) — without it the document capture-phase handler's `preventDefault()` (which never stops xterm) let each chord ALSO write its raw byte/escape sequence into Pane B's live PTY on top of whatever the app action did to Pane A (COD-153). Ctrl+Z is swallowed unless Pane B's own session is `mode === 'shell'`, mirroring terminal-ui.js's reasoning: in a plain shell it is the user's own job-control tool, everywhere else it silently suspends an unattenLine truncated
|
||||
**Split-pane sessions** (`showSplitButton`, header button, default OFF, per-device like `showFileViewerButton` — not in `SettingsUpdateSchema`, `displayKeys` in settings-ui.js): shows two live sessions side-by-side in one Codeman window. Pane A is the untouched, existing singleton terminal (`this.terminal`/`this._ws` in terminal-ui.js); Pane B is an independent `TerminalTile` (terminal-tile.js, constructed by the orchestration in terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket, whose `cid` is the tab's identity with a `:tile` suffix, so it can never supersede the primary pane's socket (the registry supersedes by cid per session). ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar (it does carry the primary pane's hollow-buffer wheel paging and desktop click report, through terminal-ui.js's own gates aimed at the tile, and its own keyCode-229 soft-keyboard controller from terminal-keycode229-recovery.js, the #441 next-keydown drain and #541's edit-based diff for Android autocorrect, since the 1180px width gate is reachable by a wide Android tablet; see terminal-tile.js's fileoverview) — since this is a desktop-only feature (a split view needs a wide viewport) and those features exist for mobile/touch input; `.btn-split` is hard-hidden below 1180px regardless of the setting by the `@media (max-width: 1179px)` rule in styles.css (mobile.css only carries a comment pointing at it: that file loads up to 1023px, so it cannot cover the 1024-1179px tablet range the feature also needs to stay off), and the per-device setting means turning it on at a desk can never sync it onto a phone in the first place. No persistence: closing the browser tab or reloading always returns to the normal single-pane view; there is no localStorage key for split state. ⚠️ Splitting a session against itself is disallowed (the picker excludes the active session), **as is splitting against a popped-out (detached) session** — `buildSplitPickerSessions()` excludes `detachedSessions` because a detached session's own window is already claiming its PTY size, and `MAX_WS_PER_SESSION` needs no change since Pane A/B are always two different sessions. ⚠️ Either pane's session ending (deleted locally or from another client) auto-collapses the split — Pane A's session ending promotes Pane B to the new single pane via `selectSession(id, { auto: true })` (an app-driven selection, so it must not spend the session's idle alert — see the Approvals Inbox note above), never by trying to hot-swap the lightweight `TerminalTile` object into the primary singleton state. ⚠️ Pane B refits on every window/sidebar/tab-rail resize via the SAME trailing-edge `ResizeObserver` callback that resizes Pane A (`throttledResize` in terminal-ui.js) — it only ever measured Pane A's own container, so without the explicit `this._forEachTile?.((tile) => tile.fit(), { grid: false })` call there (grid tiles are left out: the grid's own observer refits them on the same resize) Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket writes a `[disconnected, reconnecting…]` marker into Pane B and reconnects on the primary pane's backoff ladder (`CodemanWsReconnect` plus jitter; the attempt count resets only on a successful open), then refreshes the buffer to close the output gap. ⚠️ The open handler clears `_wsClosed`/`_markerOwed` BEFORE that refresh, or the refresh re-owes the marker and stamps it under a healthy pane. ⚠️ 4003/4004/4009/4010 stop the pane for good, report once through `onExit(code)` and write a marker saying why (`CodemanWsReconnect` alone would retry 4003). ⚠️ A replacement socket detaches the old one first and every handler ignores a socket that is no longer `this.ws`, so a late 4010 from a superseded socket cannot stop its successor; `destroy()` cancels a pending reconnect. Related but distinct: `detachSession()` already opens one session in a separate OS-level browser window (`isSoloWindow`) — that is prior art for "two sessions visible at once" but not for one window with a draggable in-page divider, which is what this feature adds. ⚠️ Pane B installs its own `attachCustomKeyEventHandler` gating the same app-level chords Pane A's own handler gates (command palette, Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter newline, smart-copy Ctrl+C) — without it the document capture-phase handler's `preventDefault()` (which never stops xterm) let each chord ALSO write its raw byte/escape sequence into Pane B's live PTY on top of whatever the app action did to Pane A (COD-153). Ctrl+Z is swallowed unless Pane B's own session is `mode === 'shell'`, mirroring terminal-ui.js's reasoning: in a plain shell it is the user's own job-control tool, everywhere else it silently suspends an unattenLine truncated
|
||||
|
||||
### Tile grid
|
||||
|
||||
|
||||
@@ -810,19 +810,20 @@
|
||||
else this.terminal?.write(data);
|
||||
}
|
||||
|
||||
// The server's `{t:'c'}` clear frame takes the same route as output, for the
|
||||
// same reason: clearing straight away, mid-replay, would wipe the half-written
|
||||
// snapshot and leave _pullHistory() measuring a buffer that is no longer the
|
||||
// one it is restoring. Queued, it lands in order with the frames around it.
|
||||
// The server's `{t:'c'}` frame, which is a refresh, not a wipe. Its one
|
||||
// emitter (Session.startInteractive, session.ts) sends it once a fresh Claude
|
||||
// pane first shows its prompt: the server has just trimmed its own buffer and
|
||||
// means "refresh after startup". The primary pane refetches the capture and
|
||||
// replays it (_onSessionClearTerminal, app.js), and while the grid is open
|
||||
// that handler stands aside for the tiles. A bare xterm clear() here kept
|
||||
// only the cursor's row and dropped the banner and every row above it, and an
|
||||
// idle Claude never repaints static rows, so a Claude session Run into the
|
||||
// grid (or Attached in a tile) sat there as a near-empty tile. So it takes
|
||||
// the `{t:'r'}` route: single-flight, coalesced into one trailing refresh
|
||||
// behind a load already running (a pull's held frames included), and paced
|
||||
// by the grid's load queue.
|
||||
_onLiveClear() {
|
||||
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true });
|
||||
else this._clearTerminal();
|
||||
}
|
||||
|
||||
// A clear leaves no rows above the screen, the pane's own overflow included.
|
||||
_clearTerminal() {
|
||||
this.terminal?.clear();
|
||||
this._overflowRows = 0;
|
||||
this._refreshBuffer();
|
||||
}
|
||||
|
||||
// Capture phase, because xterm's own wheel handler stopPropagation()s every
|
||||
@@ -931,8 +932,8 @@
|
||||
}
|
||||
|
||||
// History rows in this xterm, for the paging gate: baseY less the rows this
|
||||
// pane pushed up itself. Clamped, because a clear (Ctrl+L, a `{t:'c'}`
|
||||
// frame) or an ED3/RIS in the stream drops rows behind this count's back.
|
||||
// pane pushed up itself. Clamped, because a clear (Ctrl+L) or an ED3/RIS in
|
||||
// the stream drops rows behind this count's back.
|
||||
_localRows() {
|
||||
const baseY = this.terminal?.buffer?.active?.baseY || 0;
|
||||
this._overflowRows = Math.min(this._overflowRows, baseY);
|
||||
@@ -1103,8 +1104,7 @@
|
||||
const cutoff = replayed ? capturedAt : 0;
|
||||
for (const entry of queued) {
|
||||
if (entry.at < cutoff) continue;
|
||||
if (entry.clear) this._clearTerminal();
|
||||
else this.terminal?.write(entry.data);
|
||||
this.terminal?.write(entry.data);
|
||||
}
|
||||
// Settled after the queue flush so the marker is the last thing on
|
||||
// screen: a close during the pull wrote nothing (_onSocketClosed() defers
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -670,53 +670,85 @@ describe('TerminalTile scroll-to-top history pull', () => {
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('refreshed');
|
||||
});
|
||||
|
||||
it('a clear frame during the pull is queued in order, never applied under the replay', async () => {
|
||||
// The server's `{t:'c'}` means "refresh after startup" (its one emitter is a
|
||||
// fresh Claude pane's first prompt, session.ts), and the primary pane answers
|
||||
// it with a refetch and replay (_onSessionClearTerminal). The three below
|
||||
// replace two tests that pinned it as a bare xterm clear(), which kept only
|
||||
// the cursor's row: a Claude session Run into the grid came up a near-empty
|
||||
// tile.
|
||||
it('a clear frame during the pull is coalesced into one refresh behind it, never applied under the replay', async () => {
|
||||
const pane = makePane('shell');
|
||||
const term = pane.terminal;
|
||||
const order: string[] = [];
|
||||
term.write.mockImplementation((data: string, done?: () => void) => {
|
||||
order.push(`write:${data}`);
|
||||
if (data) order.push(`write:${data}`);
|
||||
done?.();
|
||||
});
|
||||
term.clear.mockImplementation(() => order.push('clear'));
|
||||
const held = headersOnly();
|
||||
fetchMock.mockResolvedValueOnce(held.response);
|
||||
fetchMock.mockResolvedValueOnce(held.response).mockResolvedValueOnce(jsonResponse('after startup'));
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
pane._onLiveOutput('before');
|
||||
pane._onLiveClear();
|
||||
pane._onLiveClear(); // a second one joins the same trailing refresh
|
||||
pane._onLiveOutput('after');
|
||||
// Held: clearing now would wipe a half-written snapshot.
|
||||
// Held: nothing touches the screen under the pull, and nothing fetches yet.
|
||||
expect(order).toEqual([]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(pane._bufferRefreshPending).toBe(true);
|
||||
|
||||
held.release(rowsOf(30)); // nothing to gain: no replay
|
||||
await settle();
|
||||
|
||||
expect(order).toEqual(['write:before', 'clear', 'write:after']);
|
||||
// The held frames land in order, then ONE refresh fetches the pane's
|
||||
// current screen and replays it last.
|
||||
expect(order.slice(0, 2)).toEqual(['write:before', 'write:after']);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
`/api/sessions/s1/terminal?tail=${TERMINAL_TAIL_SIZE}`,
|
||||
expect.anything()
|
||||
);
|
||||
expect(order.at(-1)).toBe('write:after startup');
|
||||
expect(pane._liveQueue).toBeNull();
|
||||
|
||||
// With nothing in flight a clear frame applies straight away.
|
||||
pane._onLiveClear();
|
||||
expect(order.at(-1)).toBe('clear');
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
expect(pane._bufferRefreshPending).toBe(false);
|
||||
});
|
||||
|
||||
it('a clear that arrived before the capture is not replayed after it', async () => {
|
||||
it('a clear frame with nothing in flight refetches the capture and replays it, like a refresh frame', async () => {
|
||||
const pane = makePane();
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse('banner\r\n❯ '));
|
||||
|
||||
pane._onLiveClear();
|
||||
await settle();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/sessions/s1/terminal?full=1', expect.anything());
|
||||
expect(screenWrites(pane).at(-1)).toBe('banner\r\n❯ ');
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('a clear frame while a pull waits for its response does not touch the screen, and refreshes behind the pull', async () => {
|
||||
const pane = makePane('shell');
|
||||
const term = pane.terminal;
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
fetchMock.mockReturnValueOnce(response.promise).mockResolvedValueOnce(jsonResponse('refreshed'));
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
clock = 1;
|
||||
pane._onLiveClear(); // before the response: applied now, already in the capture
|
||||
expect(term.clear).toHaveBeenCalledTimes(1);
|
||||
pane._onLiveClear(); // before the response
|
||||
expect(term.clear).not.toHaveBeenCalled();
|
||||
expect(pane._bufferRefreshPending).toBe(true);
|
||||
clock = 2;
|
||||
response.resolve(jsonResponse(rowsOf(100)));
|
||||
await settle();
|
||||
|
||||
expect(term.write).toHaveBeenCalledWith('\x1bc');
|
||||
expect(term.clear).toHaveBeenCalledTimes(1); // not replayed after the capture
|
||||
const writes = screenWrites(pane);
|
||||
expect(writes).toContain(rowsOf(100)); // the pull still replayed
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(writes.at(-1)).toBe('refreshed');
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('destroy() mid-pull leaves nothing running and nothing written to the dead terminal', async () => {
|
||||
|
||||
@@ -278,6 +278,24 @@ describe('refreshes', () => {
|
||||
expect(b.terminal?.writes.slice(-2)).toEqual(['<CLEAR>', 'fresh']);
|
||||
});
|
||||
|
||||
it("a server {t:'c'} (a Claude pane's first prompt) is a refresh through the same queue", async () => {
|
||||
const { tiles } = makeGrid(['a', 'b']);
|
||||
await connectAll(tiles);
|
||||
const [a, b] = tiles;
|
||||
a.ws?.receive({ t: 'r' });
|
||||
b.ws?.receive({ t: 'c' });
|
||||
await settle();
|
||||
|
||||
// b waits behind a, like any refresh: one capture in flight.
|
||||
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a']);
|
||||
expect(await drain('banner')).toBe(1);
|
||||
expect(captures.map((c) => c.url)).toEqual([
|
||||
`/api/sessions/a/terminal?full=1&tail=${TAIL}${LINES}`,
|
||||
`/api/sessions/b/terminal?full=1&tail=${TAIL}${LINES}`,
|
||||
]);
|
||||
expect(b.terminal?.writes.at(-1)).toBe('banner');
|
||||
});
|
||||
|
||||
it('a history pull jumps ahead of background refreshes', async () => {
|
||||
const { tiles } = makeGrid(['a', 'b', 'sh'], { modes: { sh: 'shell' } });
|
||||
await connectAll(tiles);
|
||||
|
||||
Reference in new issue
Block a user