hop2 0.11.10 → 0.11.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hay/CHANGELOG.md +1 -0
- package/hay/apps/server/src/rooms.ts +38 -4
- package/hay/apps/server/src/screenGrid.ts +11 -1
- package/hay/apps/server/test/rooms.test.ts +57 -2
- package/hay/apps/web/scripts/run-e2e.mjs +53 -0
- package/hay/apps/web/src/App.tsx +40 -1
- package/hay/apps/web/src/components/SessionSwitcher.tsx +8 -22
- package/hay-web/assets/{index-DQMXebVK.js → index-DQ3VWtnf.js} +21 -21
- package/hay-web/assets/{index-DQMXebVK.js.map → index-DQ3VWtnf.js.map} +1 -1
- package/hay-web/index.html +1 -1
- package/hop +34 -2
- package/package.json +1 -1
- package/scripts/hay-host.js +32 -0
- package/skills/hop-session/SKILL.md +16 -5
package/hay/CHANGELOG.md
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
#### Web terminal
|
|
36
36
|
- **Claude screens render completely on attach without a scroll**: Hop now starts its authoritative screen grid when a session enters alternate-screen or mouse-reporting TUI mode, before startup rows can fall out of a bounded replay tail. Full-screen attaches use that serialized screen, and direct snapshot paints re-arm one delayed repaint in case the browser misses the first WebGL frame.
|
|
37
|
+
- **Resizing another desktop view no longer leaves a terminal unable to reach the live bottom**: Hop now reconciles xterm's follow state after every geometry change and keeps authoritative screen resizes ordered behind the output that preceded them.
|
|
37
38
|
|
|
38
39
|
#### Session restore
|
|
39
40
|
- **`hop restore` actually restores now (Claude conversations included), from the SessionStart-hook records**: restore built its candidate list from the workspace-scoped session store, which is only populated when a workspace is active — and no workspace is ever activated in normal use, so `restore` found nothing and silently restored zero sessions. It now sources candidates directly from the durable per-session records the SessionStart hook writes (`~/.hop2/claude-sessions/<name>.json` = `{ sessionId, cwd }`), which survive a crash/reboot and carry the exact conversation id. So after a restart, `hop restore` recreates each session in its directory and resumes its Claude conversation with `claude --resume <id>` — no manual capture step. Sessions already live are skipped (idempotent), and `hop restore` now starts the daemon itself if it isn't running, so post-reboot recovery is a single command. `--dry-run` previews the plan. Records are pruned when a session is deleted, so restore never resurrects a session you intentionally closed (and if an unwanted one does come back, deleting it once keeps it gone).
|
|
@@ -575,11 +575,12 @@ export class Room extends EventEmitter {
|
|
|
575
575
|
const combined = this.controlSequenceTail + data;
|
|
576
576
|
const tailLength = this.controlSequenceTail.length;
|
|
577
577
|
const risIndex = combined.lastIndexOf("\x1bc");
|
|
578
|
-
// Only
|
|
579
|
-
// carry tail was already applied on the preceding call.
|
|
580
|
-
//
|
|
578
|
+
// Only reset tracked state for a RIS that ends in this chunk; one entirely
|
|
579
|
+
// inside the carry tail was already applied on the preceding call. Always
|
|
580
|
+
// ignore bytes before the last RIS so carried pre-reset mode enables cannot
|
|
581
|
+
// be parsed again on the next chunk.
|
|
581
582
|
const hasNewRis = risIndex !== -1 && risIndex + 2 > tailLength;
|
|
582
|
-
const stateInput =
|
|
583
|
+
const stateInput = risIndex !== -1 ? combined.slice(risIndex + 2) : combined;
|
|
583
584
|
|
|
584
585
|
// Parse CSI mode sequences (cursor visibility, alternate screen)
|
|
585
586
|
const regex = /\x1b\[\?([0-9;]*)([hl])/g;
|
|
@@ -1214,6 +1215,29 @@ export class Room extends EventEmitter {
|
|
|
1214
1215
|
return { cols: this.activeCols, rows: this.activeRows, output: this.tailOutput(maxBytes) };
|
|
1215
1216
|
}
|
|
1216
1217
|
|
|
1218
|
+
/**
|
|
1219
|
+
* The room's current screen, serialized — IDENTICAL in geometry and bytes
|
|
1220
|
+
* to what a live attach snapshots. Previews painted from this cannot
|
|
1221
|
+
* disagree with the terminal a click turns them into: that disagreement
|
|
1222
|
+
* (daemon-side grids rendering at their own capped size while the room ran
|
|
1223
|
+
* at another) was the preview → terminal size change. Modes ride in-band
|
|
1224
|
+
* like the attach path so cursor/mouse state present identically too.
|
|
1225
|
+
*/
|
|
1226
|
+
serializeScreen(
|
|
1227
|
+
callback: (result: { data: string; cols: number; rows: number } | null) => void,
|
|
1228
|
+
scrollback = 200
|
|
1229
|
+
) {
|
|
1230
|
+
if (this.outputBytes === 0) { callback(null); return; }
|
|
1231
|
+
const grid = this.ensureGrid();
|
|
1232
|
+
if (!grid) { callback(null); return; }
|
|
1233
|
+
grid.serialize((serialized) => {
|
|
1234
|
+
if (serialized === null) { callback(null); return; }
|
|
1235
|
+
const modeSeq = (this.mouseReporting ? "\x1b[?1002h" + (this.mouseSgr ? "\x1b[?1006h" : "") : "")
|
|
1236
|
+
+ (this.cursorHidden ? "\x1b[?25l" : "");
|
|
1237
|
+
callback({ data: "\x1bc" + serialized + modeSeq, cols: this.activeCols, rows: this.activeRows });
|
|
1238
|
+
}, scrollback);
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1217
1241
|
/**
|
|
1218
1242
|
* Incremental output for a persistent consumer-side screen parser (the
|
|
1219
1243
|
* daemon's preview grids). `since` is an absolute cursor from a previous
|
|
@@ -1353,6 +1377,16 @@ export class RoomManager {
|
|
|
1353
1377
|
return room ? room.getPreviewSource(maxBytes) : null;
|
|
1354
1378
|
}
|
|
1355
1379
|
|
|
1380
|
+
serializeRoomScreen(
|
|
1381
|
+
id: string,
|
|
1382
|
+
callback: (result: { data: string; cols: number; rows: number } | null) => void,
|
|
1383
|
+
scrollback?: number
|
|
1384
|
+
) {
|
|
1385
|
+
const room = this.rooms.get(id);
|
|
1386
|
+
if (!room) { callback(null); return; }
|
|
1387
|
+
room.serializeScreen(callback, scrollback);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1356
1390
|
getRoomOutputSince(id: string, since?: number, maxBytes?: number) {
|
|
1357
1391
|
const room = this.rooms.get(id);
|
|
1358
1392
|
return room ? room.getOutputSince(since, maxBytes) : null;
|
|
@@ -117,7 +117,17 @@ export const createScreenGrid = (cols: number, rows: number): ScreenGrid | null
|
|
|
117
117
|
},
|
|
118
118
|
resize(nextCols: number, nextRows: number) {
|
|
119
119
|
if (disposed) return;
|
|
120
|
-
|
|
120
|
+
const cols = Math.max(2, nextCols);
|
|
121
|
+
const rows = Math.max(1, nextRows);
|
|
122
|
+
try {
|
|
123
|
+
// xterm parses write() asynchronously. Queue the resize behind every
|
|
124
|
+
// earlier byte so room order stays PTY output → SIGWINCH, while later
|
|
125
|
+
// writes naturally queue behind this callback's synchronous resize.
|
|
126
|
+
term.write("", () => {
|
|
127
|
+
if (disposed) return;
|
|
128
|
+
try { term.resize(cols, rows); } catch { /* keep old size */ }
|
|
129
|
+
});
|
|
130
|
+
} catch { /* keep old size */ }
|
|
121
131
|
},
|
|
122
132
|
serialize(callback: (data: string | null) => void, scrollback: number = GRID_SCROLLBACK) {
|
|
123
133
|
if (disposed) { callback(null); return; }
|
|
@@ -709,7 +709,7 @@ describe("Room", () => {
|
|
|
709
709
|
});
|
|
710
710
|
|
|
711
711
|
describe("serialized attach", () => {
|
|
712
|
-
const setup = async (roomId: string) => {
|
|
712
|
+
const setup = async (roomId: string, initialSize = { cols: 80, rows: 24 }) => {
|
|
713
713
|
const { loadScreenGridDeps } = await import("../src/screenGrid");
|
|
714
714
|
expect(await loadScreenGridDeps()).toBe(true);
|
|
715
715
|
let ptyInstance: FakePty | null = null;
|
|
@@ -718,7 +718,7 @@ describe("Room", () => {
|
|
|
718
718
|
return ptyInstance as any;
|
|
719
719
|
};
|
|
720
720
|
const manager = new RoomManager(factory);
|
|
721
|
-
const room = manager.getRoom(roomId,
|
|
721
|
+
const room = manager.getRoom(roomId, initialSize, "/tmp");
|
|
722
722
|
return { room, pty: () => ptyInstance! };
|
|
723
723
|
};
|
|
724
724
|
|
|
@@ -785,6 +785,34 @@ describe("Room", () => {
|
|
|
785
785
|
expect(room.getPreviewSource(2_000_000).output).not.toContain("ROW1-EARLY-GRID-MARKER");
|
|
786
786
|
});
|
|
787
787
|
|
|
788
|
+
it("parses queued screen output before applying a later resize", async () => {
|
|
789
|
+
const { room, pty } = await setup("ordered-grid-resize", { cols: 80, rows: 60 });
|
|
790
|
+
const owner = new FakeSocket();
|
|
791
|
+
room.attachClient(
|
|
792
|
+
{ id: "owner", name: "Owner", colorIndex: 0, cols: 80, rows: 60, replayBytes: 0, nudge: false },
|
|
793
|
+
owner
|
|
794
|
+
);
|
|
795
|
+
const rows = Array.from({ length: 60 }, (_, i) => {
|
|
796
|
+
const row = String(i + 1).padStart(2, "0");
|
|
797
|
+
return `\x1b[${i + 1};1HROW-${row}`;
|
|
798
|
+
}).join("");
|
|
799
|
+
pty().emit("\x1b[?1049h\x1b[2J" + rows);
|
|
800
|
+
|
|
801
|
+
// PTY order is old-size output, then SIGWINCH. The headless grid used
|
|
802
|
+
// to resize synchronously while its earlier write was still queued,
|
|
803
|
+
// clamping rows 25-59 away before a late attach serialized the screen.
|
|
804
|
+
owner.emitMessage({ type: "resize", cols: 80, rows: 25, user: true });
|
|
805
|
+
const late = new FakeSocket();
|
|
806
|
+
room.attachClient(
|
|
807
|
+
{ id: "late", name: "Late", colorIndex: 1, cols: 80, rows: 25, replayBytes: 65536 },
|
|
808
|
+
late
|
|
809
|
+
);
|
|
810
|
+
const snapshot = await waitForSnapshot(late);
|
|
811
|
+
for (let row = 36; row <= 60; row++) {
|
|
812
|
+
expect(String(snapshot.data)).toContain(`ROW-${String(row).padStart(2, "0")}`);
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
|
|
788
816
|
it("flags a serialized snapshot as capped when deeper raw history exists", async () => {
|
|
789
817
|
const { room, pty } = await setup("capped");
|
|
790
818
|
const big = "\x1b[20;1H" + "y".repeat(4096);
|
|
@@ -853,6 +881,9 @@ describe("Room", () => {
|
|
|
853
881
|
// Split ESC c across chunks to cover the control-sequence carry path.
|
|
854
882
|
pty().emit("\x1b");
|
|
855
883
|
pty().emit("c\x1b[2J\x1b[1;1HAFTER-RIS");
|
|
884
|
+
// A later chunk must not reparse mode enables that remain before RIS in
|
|
885
|
+
// the control-sequence carry buffer.
|
|
886
|
+
pty().emit("plain");
|
|
856
887
|
const socket = new FakeSocket();
|
|
857
888
|
room.attachClient(
|
|
858
889
|
{ id: "after-ris", name: "After RIS", colorIndex: 0, cols: 80, rows: 24, replayBytes: 65536 },
|
|
@@ -867,6 +898,30 @@ describe("Room", () => {
|
|
|
867
898
|
expect(snapshot.keyboardEnhanced).toBe(false);
|
|
868
899
|
});
|
|
869
900
|
|
|
901
|
+
// The preview → terminal size change, killed at the root: a preview must
|
|
902
|
+
// be painted from the SAME grid, at the SAME geometry, as the snapshot a
|
|
903
|
+
// live attach would receive.
|
|
904
|
+
it("serializeScreen matches the live room's geometry and carries modes", async () => {
|
|
905
|
+
const { room, pty } = await setup("faithful");
|
|
906
|
+
pty().emit("\x1b[?1002h\x1b[?1006h");
|
|
907
|
+
pty().emit("\x1b[2J\x1b[1;1HFAITHFUL-MARKER");
|
|
908
|
+
const result = await new Promise((resolve) => room.serializeScreen(resolve));
|
|
909
|
+
expect(result).not.toBeNull();
|
|
910
|
+
expect((result as any).cols).toBe(80);
|
|
911
|
+
expect((result as any).rows).toBe(24);
|
|
912
|
+
expect(String((result as any).data).startsWith("\x1bc")).toBe(true);
|
|
913
|
+
expect(String((result as any).data)).toContain("FAITHFUL-MARKER");
|
|
914
|
+
expect(String((result as any).data)).toContain("\x1b[?1002h");
|
|
915
|
+
// After a size claim, the serialized geometry follows the room.
|
|
916
|
+
const socket = new FakeSocket();
|
|
917
|
+
room.attachClient({ id: "typer", name: "T", colorIndex: 0, cols: 100, rows: 30 }, socket);
|
|
918
|
+
socket.emitMessage({ type: "input", data: "x" });
|
|
919
|
+
socket.emitMessage({ type: "resize", cols: 100, rows: 30 });
|
|
920
|
+
const after = await new Promise((resolve) => room.serializeScreen(resolve));
|
|
921
|
+
expect((after as any).cols).toBe(100);
|
|
922
|
+
expect((after as any).rows).toBe(30);
|
|
923
|
+
});
|
|
924
|
+
|
|
870
925
|
it("replay=0 means no snapshot at all — live stream only", async () => {
|
|
871
926
|
const { room, pty } = await setup("claim");
|
|
872
927
|
pty().emit("some prior output\r\n");
|
|
@@ -113,6 +113,59 @@ const runE2E = async () => {
|
|
|
113
113
|
const switchedBuffer = await page1.evaluate(() => window.__hay?.getBufferText() || "");
|
|
114
114
|
assert.ok(!switchedBuffer.includes("shared"), `old room output survived session switch: ${JSON.stringify(switchedBuffer)}`);
|
|
115
115
|
|
|
116
|
+
// A resize can collapse scrollback to y=base=0 without xterm emitting an
|
|
117
|
+
// onScroll event. App follow-mode must reconcile that state before the
|
|
118
|
+
// next real room output grows below a viewport parked at 0.
|
|
119
|
+
const followAfterResize = await page1.evaluate(async () => {
|
|
120
|
+
const terminal = window.__hay.terminal;
|
|
121
|
+
const write = (data) => new Promise((resolve) => terminal.write(data, resolve));
|
|
122
|
+
terminal.reset();
|
|
123
|
+
terminal.resize(20, 5);
|
|
124
|
+
await write(Array.from({ length: 20 }, (_, i) => `old-${i}\r\n`).join(""));
|
|
125
|
+
terminal.scrollLines(-2);
|
|
126
|
+
const before = {
|
|
127
|
+
y: terminal.buffer.active.viewportY,
|
|
128
|
+
base: terminal.buffer.active.baseY
|
|
129
|
+
};
|
|
130
|
+
terminal.resize(20, 30);
|
|
131
|
+
const afterResize = {
|
|
132
|
+
y: terminal.buffer.active.viewportY,
|
|
133
|
+
base: terminal.buffer.active.baseY
|
|
134
|
+
};
|
|
135
|
+
return { before, afterResize };
|
|
136
|
+
});
|
|
137
|
+
assert.ok(followAfterResize.before.y < followAfterResize.before.base, "follow-latch setup did not scroll above bottom");
|
|
138
|
+
assert.deepEqual(followAfterResize.afterResize, { y: 0, base: 0 }, "resize did not collapse the test scrollback");
|
|
139
|
+
|
|
140
|
+
// Deliver the new rows through the room, so this covers App's actual
|
|
141
|
+
// output/follow path rather than xterm's behavior for a direct test write.
|
|
142
|
+
await page1.evaluate(async ({ roomName, data }) => {
|
|
143
|
+
const peer = new WebSocket(
|
|
144
|
+
`ws://${location.host}/ws?room=${encodeURIComponent(roomName)}&name=FollowPeer&cols=20&rows=30`
|
|
145
|
+
);
|
|
146
|
+
await new Promise((resolve, reject) => {
|
|
147
|
+
peer.onopen = resolve;
|
|
148
|
+
peer.onerror = reject;
|
|
149
|
+
});
|
|
150
|
+
window.__followPeer = peer;
|
|
151
|
+
peer.send(JSON.stringify({ type: "input", data }));
|
|
152
|
+
}, {
|
|
153
|
+
roomName: freshRoom,
|
|
154
|
+
data: Array.from({ length: 15 }, (_, i) => `new-${i}\r\n`).join("")
|
|
155
|
+
});
|
|
156
|
+
await page1.waitForFunction(() => window.__hay.terminal.buffer.active.baseY > 0);
|
|
157
|
+
followAfterResize.afterWrite = await page1.evaluate(() => ({
|
|
158
|
+
y: window.__hay.terminal.buffer.active.viewportY,
|
|
159
|
+
base: window.__hay.terminal.buffer.active.baseY
|
|
160
|
+
}));
|
|
161
|
+
await page1.evaluate(() => window.__followPeer?.close());
|
|
162
|
+
assert.ok(followAfterResize.afterWrite.base > 0, "follow-latch test did not regrow scrollback");
|
|
163
|
+
assert.equal(
|
|
164
|
+
followAfterResize.afterWrite.y,
|
|
165
|
+
followAfterResize.afterWrite.base,
|
|
166
|
+
`terminal stopped following after resize: ${JSON.stringify(followAfterResize)}`
|
|
167
|
+
);
|
|
168
|
+
|
|
116
169
|
const roomLock = `e2e-${Date.now()}-lock`;
|
|
117
170
|
const context2 = await browser.newContext({ viewport: { width: 1280, height: 720 } });
|
|
118
171
|
const page3 = await context2.newPage();
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -519,6 +519,10 @@ const App = () => {
|
|
|
519
519
|
const shouldReconnectRef = useRef(true);
|
|
520
520
|
const connectNonceRef = useRef(0);
|
|
521
521
|
const userScrolledUpRef = useRef(false);
|
|
522
|
+
// A resize can collapse a previously scrolled viewport to y=base=0 without
|
|
523
|
+
// clearing xterm's internal user-scroll state. The next output must repair
|
|
524
|
+
// follow mode after xterm has actually grown the buffer again.
|
|
525
|
+
const followAfterResizeRef = useRef(false);
|
|
522
526
|
// Set by every write into the terminal; the render watchdog only spends a
|
|
523
527
|
// refresh when something actually changed since its last tick.
|
|
524
528
|
const outputSinceTickRef = useRef(true);
|
|
@@ -1272,9 +1276,14 @@ const App = () => {
|
|
|
1272
1276
|
|
|
1273
1277
|
// Filter focus reporting sequences that can leak as visible text
|
|
1274
1278
|
const filtered = data.replace(/\x1b\[I/g, '').replace(/\x1b\[O/g, '');
|
|
1279
|
+
const repairFollowAfterResize = followAfterResizeRef.current;
|
|
1275
1280
|
termRef.current.write(filtered, () => {
|
|
1281
|
+
if (repairFollowAfterResize) {
|
|
1282
|
+
followAfterResizeRef.current = false;
|
|
1283
|
+
userScrolledUpRef.current = false;
|
|
1284
|
+
}
|
|
1276
1285
|
// Auto-scroll to bottom unless the user has explicitly scrolled up
|
|
1277
|
-
if (!userScrolledUpRef.current) {
|
|
1286
|
+
if (repairFollowAfterResize || !userScrolledUpRef.current) {
|
|
1278
1287
|
termRef.current?.scrollToBottom();
|
|
1279
1288
|
}
|
|
1280
1289
|
});
|
|
@@ -1319,6 +1328,12 @@ const App = () => {
|
|
|
1319
1328
|
activeSizeRef.current = null;
|
|
1320
1329
|
optimisticEchoRef.current.reset();
|
|
1321
1330
|
userScrolledUpRef.current = false;
|
|
1331
|
+
followAfterResizeRef.current = false;
|
|
1332
|
+
// reset() can emit onScroll(0). Clear the previous room's history guards
|
|
1333
|
+
// first so that event cannot masquerade as a real scroll-to-top gesture.
|
|
1334
|
+
lastViewportYRef.current = 0;
|
|
1335
|
+
snapshotSettledAtRef.current = 0;
|
|
1336
|
+
snapshotWasCappedRef.current = false;
|
|
1322
1337
|
termRef.current?.reset();
|
|
1323
1338
|
|
|
1324
1339
|
// Fast first paint: the daemon can serialize the session's CURRENT
|
|
@@ -1944,6 +1959,30 @@ const App = () => {
|
|
|
1944
1959
|
syncOverlayScrollbar();
|
|
1945
1960
|
};
|
|
1946
1961
|
terminal.onScroll(onBufferScroll);
|
|
1962
|
+
// xterm does not always emit onScroll when a resize collapses scrollback.
|
|
1963
|
+
// Its public y/base can then say "bottom" while both our follow flag and
|
|
1964
|
+
// xterm's private user-scroll latch remain stuck above it. Reconcile every
|
|
1965
|
+
// resize from buffer truth and remember to repair follow mode after the
|
|
1966
|
+
// next write has regrown the buffer (scrollToBottom is a no-op at 0/0).
|
|
1967
|
+
terminal.onResize(() => {
|
|
1968
|
+
// onResize fires inside xterm's resize transaction, before its private
|
|
1969
|
+
// user-scroll latch has fully settled. Reconcile just after resize()
|
|
1970
|
+
// returns, still ahead of terminal writes queued later in the turn.
|
|
1971
|
+
queueMicrotask(() => {
|
|
1972
|
+
if (termRef.current !== terminal) return;
|
|
1973
|
+
const { y, base } = scrollState();
|
|
1974
|
+
lastViewportYRef.current = y;
|
|
1975
|
+
if (y >= base) {
|
|
1976
|
+
userScrolledUpRef.current = false;
|
|
1977
|
+
followAfterResizeRef.current = true;
|
|
1978
|
+
terminal.scrollToBottom();
|
|
1979
|
+
} else {
|
|
1980
|
+
userScrolledUpRef.current = true;
|
|
1981
|
+
followAfterResizeRef.current = false;
|
|
1982
|
+
}
|
|
1983
|
+
syncOverlayScrollbar();
|
|
1984
|
+
});
|
|
1985
|
+
});
|
|
1947
1986
|
// Buffer growth while following doesn't always fire onScroll — a light
|
|
1948
1987
|
// interval keeps the thumb honest.
|
|
1949
1988
|
const overlayInterval = window.setInterval(syncOverlayScrollbar, 700);
|
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
} from "react";
|
|
12
12
|
import { Terminal } from "@xterm/xterm";
|
|
13
13
|
import { FitAddon } from "@xterm/addon-fit";
|
|
14
|
-
import { WebglAddon } from "@xterm/addon-webgl";
|
|
15
14
|
import { attachScrollFlywheel } from "../utils/scrollFlywheel";
|
|
16
15
|
import { ContextMenu, type MenuRequest } from "./ContextMenu";
|
|
17
16
|
import { collectTerminalMatches, selectTerminalMatch } from "../utils/terminalSearch";
|
|
@@ -829,24 +828,14 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
829
828
|
// terminal has had it all along, which is most of why a live tile felt
|
|
830
829
|
// less responsive than full screen. Only the live tile: browsers cap
|
|
831
830
|
// concurrent WebGL contexts (~16) and a wall can hold more tiles.
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
webgl = new WebglAddon();
|
|
841
|
-
webgl.onContextLoss(() => { webgl?.dispose(); webgl = null; });
|
|
842
|
-
term.loadAddon(webgl);
|
|
843
|
-
term.refresh(0, term.rows - 1);
|
|
844
|
-
} catch {
|
|
845
|
-
try { webgl?.dispose(); } catch { /* half-constructed */ }
|
|
846
|
-
webgl = null;
|
|
847
|
-
webglWanted = false;
|
|
848
|
-
}
|
|
849
|
-
};
|
|
831
|
+
// ONE renderer for the wall, in BOTH modes. WebGL used to load only when
|
|
832
|
+
// a tile went live, so the flip changed glyph quantization mid-swap (the
|
|
833
|
+
// WebGL renderer floors cell widths to device pixels; the DOM renderer
|
|
834
|
+
// doesn't) — a size change no metric-matching could fully erase, plus a
|
|
835
|
+
// browser cap of ~16 WebGL contexts a big wall can exceed, plus the
|
|
836
|
+
// texture-atlas blank-cell class. Tiles are small; the DOM renderer is
|
|
837
|
+
// plenty, and preview → live becomes the SAME renderer painting the same
|
|
838
|
+
// bytes at the same geometry. Full screen keeps WebGL.
|
|
850
839
|
setConn("live");
|
|
851
840
|
const sep = wsBase.includes("?") ? "&" : "?";
|
|
852
841
|
const wsUrl = () =>
|
|
@@ -903,7 +892,6 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
903
892
|
term.scrollToBottom();
|
|
904
893
|
term.refresh(0, term.rows - 1);
|
|
905
894
|
rescaleRef.current();
|
|
906
|
-
requestAnimationFrame(loadGpu);
|
|
907
895
|
});
|
|
908
896
|
kbdEnhancedRef.current = typeof m.keyboardEnhanced === "boolean"
|
|
909
897
|
? m.keyboardEnhanced
|
|
@@ -978,8 +966,6 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
978
966
|
const focusTimer = window.setTimeout(() => { if (terminalMayTakeFocus()) term.focus(); }, 50);
|
|
979
967
|
return () => {
|
|
980
968
|
disposed = true;
|
|
981
|
-
try { webgl?.dispose(); } catch { /* already gone */ }
|
|
982
|
-
webgl = null;
|
|
983
969
|
window.clearTimeout(focusTimer);
|
|
984
970
|
window.clearTimeout(reconnectTimer);
|
|
985
971
|
document.removeEventListener("visibilitychange", onVisibility);
|