hop2 0.11.9 → 0.11.10
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 +3 -0
- package/hay/apps/server/src/rooms.ts +83 -25
- package/hay/apps/server/test/rooms.test.ts +57 -0
- package/hay/apps/web/src/App.tsx +34 -2
- package/hay/apps/web/src/components/SessionSwitcher.tsx +4 -0
- package/hay/apps/web/src/utils/scrollFlywheel.ts +24 -5
- package/hay-web/assets/{index-BJnLaQd5.css → index-D4EXRUpb.css} +1 -1
- package/hay-web/assets/{index-ClpDI5hp.js → index-DQMXebVK.js} +16 -16
- package/hay-web/assets/index-DQMXebVK.js.map +1 -0
- package/hay-web/index.html +2 -2
- package/hop +25 -1
- package/package.json +1 -1
- package/hay-web/assets/index-ClpDI5hp.js.map +0 -1
package/hay/CHANGELOG.md
CHANGED
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
|
|
33
33
|
### Bug Fixes
|
|
34
34
|
|
|
35
|
+
#### Web terminal
|
|
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
|
+
|
|
35
38
|
#### Session restore
|
|
36
39
|
- **`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).
|
|
37
40
|
- **`hopx_agent_turn` no longer runs non-UI input twice**: in `readable_raw` mode (and `auto` resolving to it — i.e. a plain shell or any non-alternate-screen agent), a synchronous turn pre-sent the input and *then* sent it again via the combined send+wait, so a command driven through `hopx_agent_turn` executed twice. The pre-send is now done only for the paths that need it (the `ui` branch and async turns, which return before the combined call); non-UI synchronous turns let the single send+wait do the send. UI and async turns are unchanged.
|
|
@@ -179,6 +179,7 @@ class ClientState {
|
|
|
179
179
|
// Output broadcasts are withheld meanwhile (see broadcast) and delivered
|
|
180
180
|
// as catch-up appended to the snapshot itself.
|
|
181
181
|
awaitingSnapshot = false;
|
|
182
|
+
snapshotCatchUp: string[] = [];
|
|
182
183
|
id: string;
|
|
183
184
|
name: string;
|
|
184
185
|
color: string;
|
|
@@ -261,10 +262,15 @@ export class Room extends EventEmitter {
|
|
|
261
262
|
// connect created the room, so its hello carries created=true.
|
|
262
263
|
private hasHadClient = false;
|
|
263
264
|
// Authoritative screen state for serialized attach (see screenGrid.ts).
|
|
264
|
-
//
|
|
265
|
-
//
|
|
265
|
+
// Start it as soon as a TUI advertises alternate-screen or mouse mode, so
|
|
266
|
+
// rows painted once at startup cannot age out of a bounded replay tail.
|
|
267
|
+
// Plain shells stay lazy and pay no grid cost until somebody attaches.
|
|
266
268
|
private grid: ScreenGrid | null = null;
|
|
267
269
|
private gridHydrated = false;
|
|
270
|
+
private gridStartPending = false;
|
|
271
|
+
private gridUnavailable = false;
|
|
272
|
+
private gridPendingSeed: string[] | null = null;
|
|
273
|
+
private gridPendingSeedBytes = 0;
|
|
268
274
|
|
|
269
275
|
constructor(id: string, ptyFactory: PtyFactory, initialSize: { cols: number; rows: number }, options: RoomCreateOptions) {
|
|
270
276
|
super();
|
|
@@ -297,6 +303,7 @@ export class Room extends EventEmitter {
|
|
|
297
303
|
this.lastActivityAt = now();
|
|
298
304
|
this.appendOutput(data);
|
|
299
305
|
this.updateTerminalState(data);
|
|
306
|
+
this.startTuiGrid();
|
|
300
307
|
this.answerTerminalQueriesIfHeadless(data);
|
|
301
308
|
this.broadcast({ type: "output", data });
|
|
302
309
|
this.emit("pty_output", { roomId: this.id, data, timestamp: now() });
|
|
@@ -445,19 +452,20 @@ export class Room extends EventEmitter {
|
|
|
445
452
|
// Everything already in the ring at THIS moment will be in the
|
|
446
453
|
// serialized screen; everything after it will not (grid writes queue
|
|
447
454
|
// behind the serialize marker). Mute output to this client until the
|
|
448
|
-
// snapshot goes out,
|
|
455
|
+
// snapshot goes out, retaining the exact withheld chunks for catch-up.
|
|
449
456
|
client.awaitingSnapshot = true;
|
|
450
|
-
|
|
457
|
+
client.snapshotCatchUp = [];
|
|
451
458
|
gridForAttach.serialize((serialized) => {
|
|
452
459
|
if (this.ended || !this.clients.has(client.id) || !socket.isOpen()) {
|
|
453
460
|
client.awaitingSnapshot = false;
|
|
461
|
+
client.snapshotCatchUp = [];
|
|
454
462
|
return;
|
|
455
463
|
}
|
|
456
464
|
if (serialized === null) {
|
|
457
|
-
// Raw fallback reads the ring as it stands NOW
|
|
458
|
-
//
|
|
459
|
-
// into the erased-by-reset gap either (raw has no RIS).
|
|
465
|
+
// Raw fallback reads the ring as it stands NOW. Unmute first so
|
|
466
|
+
// nothing new slips into the handoff (raw has no in-band RIS).
|
|
460
467
|
client.awaitingSnapshot = false;
|
|
468
|
+
client.snapshotCatchUp = [];
|
|
461
469
|
sendRawSnapshot();
|
|
462
470
|
wiggle();
|
|
463
471
|
return;
|
|
@@ -473,16 +481,17 @@ export class Room extends EventEmitter {
|
|
|
473
481
|
// Catch-up: output that arrived while the snapshot was being
|
|
474
482
|
// serialized. Appended INSIDE the snapshot payload so snapshot and
|
|
475
483
|
// delta land as one atomic parse — no frame between reset and delta.
|
|
476
|
-
const catchUp =
|
|
484
|
+
const catchUp = client.snapshotCatchUp.join("");
|
|
477
485
|
socket.send(JSON.stringify({
|
|
478
486
|
type: "snapshot",
|
|
479
487
|
// In-band RIS: every client applies the serialized screen onto a
|
|
480
488
|
// clean slate, including ones that don't reset on their own.
|
|
481
|
-
data: "\x1bc" + serialized + modeSeq +
|
|
489
|
+
data: "\x1bc" + serialized + modeSeq + catchUp,
|
|
482
490
|
capped: this.outputBytes > SERIALIZED_CAPPED_HINT_BYTES,
|
|
483
491
|
...snapshotFlags()
|
|
484
492
|
} satisfies ServerMessage));
|
|
485
493
|
client.awaitingSnapshot = false;
|
|
494
|
+
client.snapshotCatchUp = [];
|
|
486
495
|
// No wiggle: the serialized grid IS the current screen.
|
|
487
496
|
}, serializedScrollback);
|
|
488
497
|
} else {
|
|
@@ -564,16 +573,23 @@ export class Room extends EventEmitter {
|
|
|
564
573
|
|
|
565
574
|
private updateTerminalState(data: string) {
|
|
566
575
|
const combined = this.controlSequenceTail + data;
|
|
576
|
+
const tailLength = this.controlSequenceTail.length;
|
|
577
|
+
const risIndex = combined.lastIndexOf("\x1bc");
|
|
578
|
+
// Only process a RIS that ends in this chunk; one entirely inside the
|
|
579
|
+
// carry tail was already applied on the preceding call. Modes before the
|
|
580
|
+
// last new RIS are reset, while later sequences in the same chunk win.
|
|
581
|
+
const hasNewRis = risIndex !== -1 && risIndex + 2 > tailLength;
|
|
582
|
+
const stateInput = hasNewRis ? combined.slice(risIndex + 2) : combined;
|
|
567
583
|
|
|
568
584
|
// Parse CSI mode sequences (cursor visibility, alternate screen)
|
|
569
585
|
const regex = /\x1b\[\?([0-9;]*)([hl])/g;
|
|
570
586
|
let match: RegExpExecArray | null;
|
|
571
|
-
let nextAlternate = this.alternateScreen;
|
|
572
|
-
let nextCursorHidden = this.cursorHidden;
|
|
587
|
+
let nextAlternate = hasNewRis ? false : this.alternateScreen;
|
|
588
|
+
let nextCursorHidden = hasNewRis ? false : this.cursorHidden;
|
|
573
589
|
|
|
574
|
-
let nextMouseReporting = this.mouseReporting;
|
|
575
|
-
let nextMouseSgr = this.mouseSgr;
|
|
576
|
-
while ((match = regex.exec(
|
|
590
|
+
let nextMouseReporting = hasNewRis ? false : this.mouseReporting;
|
|
591
|
+
let nextMouseSgr = hasNewRis ? false : this.mouseSgr;
|
|
592
|
+
while ((match = regex.exec(stateInput)) !== null) {
|
|
577
593
|
const params = match[1].split(";").filter(Boolean);
|
|
578
594
|
const mode = match[2];
|
|
579
595
|
for (const param of params) {
|
|
@@ -593,10 +609,10 @@ export class Room extends EventEmitter {
|
|
|
593
609
|
|
|
594
610
|
// Enhanced keyboard reporting: kitty keyboard protocol (CSI >flags u push /
|
|
595
611
|
// CSI <n u pop / CSI =flags u set) and xterm modifyOtherKeys (CSI >4;n m).
|
|
596
|
-
let nextKeyboardEnhanced = this.keyboardEnhanced;
|
|
612
|
+
let nextKeyboardEnhanced = hasNewRis ? false : this.keyboardEnhanced;
|
|
597
613
|
const kbdRegex = /\x1b\[([<>=])[0-9;:]*u|\x1b\[>([0-9;]*)m/g;
|
|
598
614
|
let kbdMatch: RegExpExecArray | null;
|
|
599
|
-
while ((kbdMatch = kbdRegex.exec(
|
|
615
|
+
while ((kbdMatch = kbdRegex.exec(stateInput)) !== null) {
|
|
600
616
|
if (kbdMatch[1] !== undefined) {
|
|
601
617
|
nextKeyboardEnhanced = kbdMatch[1] !== "<"; // push/set = on, pop = off
|
|
602
618
|
} else {
|
|
@@ -954,7 +970,10 @@ export class Room extends EventEmitter {
|
|
|
954
970
|
// serialize marker). That silent loss was 'the screen is stale until
|
|
955
971
|
// the app repaints' on every attach that raced live output. The
|
|
956
972
|
// withheld bytes are delivered as catch-up appended to the snapshot.
|
|
957
|
-
if (isOutput && client.awaitingSnapshot)
|
|
973
|
+
if (isOutput && client.awaitingSnapshot) {
|
|
974
|
+
client.snapshotCatchUp.push((message as { data: string }).data);
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
958
977
|
if (client.socket.isOpen()) {
|
|
959
978
|
client.socket.send(payload);
|
|
960
979
|
}
|
|
@@ -1052,13 +1071,38 @@ export class Room extends EventEmitter {
|
|
|
1052
1071
|
}
|
|
1053
1072
|
}
|
|
1054
1073
|
|
|
1055
|
-
//
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
1074
|
+
// Incremental TUIs paint some rows once and then update other regions for
|
|
1075
|
+
// hours. Reconstructing their first grid from any bounded tail can therefore
|
|
1076
|
+
// omit cells until the app happens to repaint (for example, on scroll).
|
|
1077
|
+
// Begin tracking at TUI entry; dependency loading is shared and normally
|
|
1078
|
+
// finishes first, but the callback covers a TUI that starts immediately.
|
|
1079
|
+
private startTuiGrid() {
|
|
1080
|
+
if (!SERIALIZED_ATTACH_ENABLED || this.grid || this.gridStartPending || this.gridUnavailable || this.ended) return;
|
|
1081
|
+
if (!this.alternateScreen && !this.mouseReporting) return;
|
|
1082
|
+
if (screenGridsAvailable()) {
|
|
1083
|
+
this.gridUnavailable = this.ensureGrid() === null;
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
this.gridStartPending = true;
|
|
1087
|
+
this.gridPendingSeed = [this.tailOutput(MAX_BUFFER_SIZE)];
|
|
1088
|
+
this.gridPendingSeedBytes = this.gridPendingSeed[0].length;
|
|
1089
|
+
void loadScreenGridDeps().then((available) => {
|
|
1090
|
+
this.gridStartPending = false;
|
|
1091
|
+
this.gridUnavailable = !available;
|
|
1092
|
+
const seed = this.gridPendingSeed?.join("") ?? "";
|
|
1093
|
+
this.gridPendingSeed = null;
|
|
1094
|
+
this.gridPendingSeedBytes = 0;
|
|
1095
|
+
if (available && !this.ended) {
|
|
1096
|
+
this.gridUnavailable = this.ensureGrid(seed) === null;
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Plain-shell grids remain lazy until the first serialized attach. Creation
|
|
1102
|
+
// hydrates from a bounded ring tail; append-only shell output is safely
|
|
1103
|
+
// reconstructible from that tail, unlike the incremental TUI path above.
|
|
1060
1104
|
private static readonly GRID_HYDRATE_BYTES = 2_000_000;
|
|
1061
|
-
private ensureGrid(): ScreenGrid | null {
|
|
1105
|
+
private ensureGrid(seed?: string): ScreenGrid | null {
|
|
1062
1106
|
if (!SERIALIZED_ATTACH_ENABLED || this.ended) return this.grid;
|
|
1063
1107
|
if (!this.grid && screenGridsAvailable()) {
|
|
1064
1108
|
this.grid = createScreenGrid(this.activeCols, this.activeRows);
|
|
@@ -1067,8 +1111,8 @@ export class Room extends EventEmitter {
|
|
|
1067
1111
|
if (this.outputBytes > 0) {
|
|
1068
1112
|
// Cut at a line boundary (same trick as boundSnapshotReplay) so a
|
|
1069
1113
|
// slice landing mid-escape-sequence can't garble the parse head.
|
|
1070
|
-
let tail = this.tailOutput(Room.GRID_HYDRATE_BYTES);
|
|
1071
|
-
if (this.outputBytes > tail.length) {
|
|
1114
|
+
let tail = seed ?? this.tailOutput(Room.GRID_HYDRATE_BYTES);
|
|
1115
|
+
if (seed === undefined && this.outputBytes > tail.length) {
|
|
1072
1116
|
const nl = tail.indexOf("\n");
|
|
1073
1117
|
if (nl !== -1 && nl < 8192) tail = tail.slice(nl + 1);
|
|
1074
1118
|
}
|
|
@@ -1082,6 +1126,20 @@ export class Room extends EventEmitter {
|
|
|
1082
1126
|
/** O(1) append; trims whole chunks from the head past MAX_BUFFER_SIZE. */
|
|
1083
1127
|
private appendOutput(data: string) {
|
|
1084
1128
|
if (!data) return;
|
|
1129
|
+
if (this.gridPendingSeed) {
|
|
1130
|
+
this.gridPendingSeed.push(data);
|
|
1131
|
+
this.gridPendingSeedBytes += data.length;
|
|
1132
|
+
while (this.gridPendingSeed.length > 1
|
|
1133
|
+
&& this.gridPendingSeedBytes - this.gridPendingSeed[0].length >= MAX_BUFFER_SIZE) {
|
|
1134
|
+
this.gridPendingSeedBytes -= this.gridPendingSeed[0].length;
|
|
1135
|
+
this.gridPendingSeed.shift();
|
|
1136
|
+
}
|
|
1137
|
+
if (this.gridPendingSeed.length === 1 && this.gridPendingSeedBytes > MAX_BUFFER_SIZE) {
|
|
1138
|
+
const tail = this.gridPendingSeed[0].slice(-MAX_BUFFER_SIZE);
|
|
1139
|
+
this.gridPendingSeed[0] = tail;
|
|
1140
|
+
this.gridPendingSeedBytes = tail.length;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1085
1143
|
this.outputChunks.push(data);
|
|
1086
1144
|
this.outputBytes += data.length;
|
|
1087
1145
|
while (this.outputChunks.length > 1 && this.outputBytes - this.outputChunks[0].length >= MAX_BUFFER_SIZE) {
|
|
@@ -766,6 +766,25 @@ describe("Room", () => {
|
|
|
766
766
|
expect(snapshot.mouseReporting).toBe(true);
|
|
767
767
|
});
|
|
768
768
|
|
|
769
|
+
it("tracks TUI rows before they age out of the grid hydration tail", async () => {
|
|
770
|
+
const { room, pty } = await setup("early-grid");
|
|
771
|
+
pty().emit("\x1b[?1002h\x1b[?1006h");
|
|
772
|
+
pty().emit("\x1b[2J\x1b[1;1HROW1-EARLY-GRID-MARKER");
|
|
773
|
+
// Repaint only row 20 for more than the lazy grid's 2MB hydration
|
|
774
|
+
// window. A grid first created at attach would have no row 1 to send.
|
|
775
|
+
const updateBatch = ("\x1b[20;1H\x1b[K" + "x".repeat(60)).repeat(64);
|
|
776
|
+
for (let i = 0; i < 500; i++) pty().emit(updateBatch);
|
|
777
|
+
|
|
778
|
+
const socket = new FakeSocket();
|
|
779
|
+
room.attachClient(
|
|
780
|
+
{ id: "late", name: "Late", colorIndex: 0, cols: 80, rows: 24, replayBytes: 65536 },
|
|
781
|
+
socket
|
|
782
|
+
);
|
|
783
|
+
const snapshot = await waitForSnapshot(socket);
|
|
784
|
+
expect(String(snapshot.data)).toContain("ROW1-EARLY-GRID-MARKER");
|
|
785
|
+
expect(room.getPreviewSource(2_000_000).output).not.toContain("ROW1-EARLY-GRID-MARKER");
|
|
786
|
+
});
|
|
787
|
+
|
|
769
788
|
it("flags a serialized snapshot as capped when deeper raw history exists", async () => {
|
|
770
789
|
const { room, pty } = await setup("capped");
|
|
771
790
|
const big = "\x1b[20;1H" + "y".repeat(4096);
|
|
@@ -810,6 +829,44 @@ describe("Room", () => {
|
|
|
810
829
|
expect(after.some((m) => String(m.data).includes("LATE-MARKER-XYZ"))).toBe(false);
|
|
811
830
|
});
|
|
812
831
|
|
|
832
|
+
it("retains catch-up larger than the incremental preview limit", async () => {
|
|
833
|
+
const { room, pty } = await setup("large-race");
|
|
834
|
+
pty().emit("\x1b[?1002h\x1b[2J\x1b[1;1Hbefore large catch-up");
|
|
835
|
+
const socket = new FakeSocket();
|
|
836
|
+
room.attachClient(
|
|
837
|
+
{ id: "large-racer", name: "Large Racer", colorIndex: 0, cols: 80, rows: 24, replayBytes: 65536 },
|
|
838
|
+
socket
|
|
839
|
+
);
|
|
840
|
+
// Larger than getOutputSince's 1MB default: cursor-based catch-up used
|
|
841
|
+
// to reset here and silently drop every withheld byte.
|
|
842
|
+
const late = "x".repeat(1_100_000) + "LARGE-CATCHUP-MARKER";
|
|
843
|
+
pty().emit(late);
|
|
844
|
+
const snapshot = await waitForSnapshot(socket);
|
|
845
|
+
expect(String(snapshot.data)).toContain("LARGE-CATCHUP-MARKER");
|
|
846
|
+
const live = readMessages(socket).filter((m) => m.type === "output");
|
|
847
|
+
expect(live.some((m) => String(m.data).includes("LARGE-CATCHUP-MARKER"))).toBe(false);
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
it("clears tracked private modes when the app sends RIS", async () => {
|
|
851
|
+
const { room, pty } = await setup("ris-modes");
|
|
852
|
+
pty().emit("\x1b[?1049h\x1b[?1002h\x1b[?1006h\x1b[?25l\x1b[>1u");
|
|
853
|
+
// Split ESC c across chunks to cover the control-sequence carry path.
|
|
854
|
+
pty().emit("\x1b");
|
|
855
|
+
pty().emit("c\x1b[2J\x1b[1;1HAFTER-RIS");
|
|
856
|
+
const socket = new FakeSocket();
|
|
857
|
+
room.attachClient(
|
|
858
|
+
{ id: "after-ris", name: "After RIS", colorIndex: 0, cols: 80, rows: 24, replayBytes: 65536 },
|
|
859
|
+
socket
|
|
860
|
+
);
|
|
861
|
+
const snapshot = await waitForSnapshot(socket);
|
|
862
|
+
expect(String(snapshot.data)).toContain("AFTER-RIS");
|
|
863
|
+
expect(snapshot.alternateScreen).toBe(false);
|
|
864
|
+
expect(snapshot.mouseReporting).toBe(false);
|
|
865
|
+
expect(snapshot.mouseSgr).toBe(false);
|
|
866
|
+
expect(snapshot.cursorHidden).toBe(false);
|
|
867
|
+
expect(snapshot.keyboardEnhanced).toBe(false);
|
|
868
|
+
});
|
|
869
|
+
|
|
813
870
|
it("replay=0 means no snapshot at all — live stream only", async () => {
|
|
814
871
|
const { room, pty } = await setup("claim");
|
|
815
872
|
pty().emit("some prior output\r\n");
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -519,6 +519,12 @@ const App = () => {
|
|
|
519
519
|
const shouldReconnectRef = useRef(true);
|
|
520
520
|
const connectNonceRef = useRef(0);
|
|
521
521
|
const userScrolledUpRef = useRef(false);
|
|
522
|
+
// Set by every write into the terminal; the render watchdog only spends a
|
|
523
|
+
// refresh when something actually changed since its last tick.
|
|
524
|
+
const outputSinceTickRef = useRef(true);
|
|
525
|
+
// Timestamp of the last bytes from the session — the ack signal that
|
|
526
|
+
// paces synthetic wheel steps (see scrollFlywheel appGlide).
|
|
527
|
+
const lastOutputAtRef = useRef(0);
|
|
522
528
|
// Late-bound hooks for the voice controller (created before these exist).
|
|
523
529
|
const handleUserInputRef = useRef<((data: string) => void) | null>(null);
|
|
524
530
|
const pushNoticeRef = useRef<((m: string) => void) | null>(null);
|
|
@@ -928,8 +934,18 @@ const App = () => {
|
|
|
928
934
|
return presence.find((client) => client.id === controllerId)?.name ?? "Someone";
|
|
929
935
|
}, [presence, controllerId]);
|
|
930
936
|
|
|
937
|
+
// Local echo policy. Exclusive control keeps the old rule. In COLLAB mode
|
|
938
|
+
// (the default for every shared session) echo used to be flatly OFF — so
|
|
939
|
+
// each character waited a full round trip, tunnel RTT included, even when
|
|
940
|
+
// you were the only person typing, which is the overwhelmingly common
|
|
941
|
+
// case. Echo now stays on unless another client is ACTUALLY typing right
|
|
942
|
+
// now (presence carries the flag), and drops the instant one starts. A
|
|
943
|
+
// sub-second overlap before presence propagates is absorbed by the
|
|
944
|
+
// reconciler's guards (complex-chunk passthrough, two-strike clear).
|
|
945
|
+
const othersTyping = presence.some((c) => c.id !== clientId && c.typing);
|
|
931
946
|
const optimisticActive =
|
|
932
|
-
LATENCY_COMP && status === "connected" && !
|
|
947
|
+
LATENCY_COMP && status === "connected" && !othersTyping
|
|
948
|
+
&& (collabMode ? true : controllerId === clientId);
|
|
933
949
|
|
|
934
950
|
useEffect(() => {
|
|
935
951
|
if (optimisticPrevRef.current && !optimisticActive) {
|
|
@@ -1251,6 +1267,8 @@ const App = () => {
|
|
|
1251
1267
|
if (!termRef.current) {
|
|
1252
1268
|
return;
|
|
1253
1269
|
}
|
|
1270
|
+
outputSinceTickRef.current = true;
|
|
1271
|
+
lastOutputAtRef.current = performance.now(); // same clock the flywheel compares
|
|
1254
1272
|
|
|
1255
1273
|
// Filter focus reporting sequences that can leak as visible text
|
|
1256
1274
|
const filtered = data.replace(/\x1b\[I/g, '').replace(/\x1b\[O/g, '');
|
|
@@ -1339,6 +1357,9 @@ const App = () => {
|
|
|
1339
1357
|
// snapshot and must land where the snapshot would.
|
|
1340
1358
|
t.scrollToBottom();
|
|
1341
1359
|
t.refresh(0, t.rows - 1);
|
|
1360
|
+
// This write bypasses writeToTerminal(). Re-arm the delayed render
|
|
1361
|
+
// invariant after parsing, so a missed WebGL frame self-heals.
|
|
1362
|
+
outputSinceTickRef.current = true;
|
|
1342
1363
|
});
|
|
1343
1364
|
})
|
|
1344
1365
|
.catch(() => { /* fast paint is best-effort */ });
|
|
@@ -1504,6 +1525,10 @@ const App = () => {
|
|
|
1504
1525
|
userScrolledUpRef.current = false;
|
|
1505
1526
|
}
|
|
1506
1527
|
t.refresh(0, t.rows - 1);
|
|
1528
|
+
// Snapshot writes bypass writeToTerminal(). Set this only after
|
|
1529
|
+
// parsing so a slow snapshot cannot have its retry consumed
|
|
1530
|
+
// before there is anything complete to repaint.
|
|
1531
|
+
outputSinceTickRef.current = true;
|
|
1507
1532
|
// The snapshot has fully landed: only from here on can a
|
|
1508
1533
|
// viewport-top event mean a user gesture.
|
|
1509
1534
|
lastViewportYRef.current = t.buffer.active.viewportY;
|
|
@@ -1699,7 +1724,8 @@ const App = () => {
|
|
|
1699
1724
|
() => termRef.current,
|
|
1700
1725
|
{
|
|
1701
1726
|
linesPerNotch: 4, // mirrors scrollSensitivity above
|
|
1702
|
-
lineHeightPx: () => Math.max(1, ((terminal as any)._core?._renderService?.dimensions?.css?.cell?.height) || fontSize * 1.3)
|
|
1727
|
+
lineHeightPx: () => Math.max(1, ((terminal as any)._core?._renderService?.dimensions?.css?.cell?.height) || fontSize * 1.3),
|
|
1728
|
+
lastOutputAt: () => lastOutputAtRef.current
|
|
1703
1729
|
}
|
|
1704
1730
|
);
|
|
1705
1731
|
}
|
|
@@ -2565,6 +2591,12 @@ const App = () => {
|
|
|
2565
2591
|
if (document.hidden || switcherOpenRef.current) return;
|
|
2566
2592
|
const t = termRef.current;
|
|
2567
2593
|
if (!t) return;
|
|
2594
|
+
// Idle sessions get no tick: a full refresh every 1.4s on a screen
|
|
2595
|
+
// nothing wrote to re-rasterizes identical rows forever (measurable
|
|
2596
|
+
// on battery with several sessions open). Output sets the flag; one
|
|
2597
|
+
// enforcement pass clears it.
|
|
2598
|
+
if (!outputSinceTickRef.current) return;
|
|
2599
|
+
outputSinceTickRef.current = false;
|
|
2568
2600
|
try {
|
|
2569
2601
|
const buf = t.buffer.active;
|
|
2570
2602
|
if (!userScrolledUpRef.current && buf.viewportY < buf.baseY) t.scrollToBottom();
|
|
@@ -346,6 +346,8 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
346
346
|
const kbdEnhancedRef = useRef(false);
|
|
347
347
|
// null = watching; "live"/"down" = connected state for the chrome.
|
|
348
348
|
const [conn, setConn] = useState<"live" | "down" | null>(null);
|
|
349
|
+
// Last bytes from the session — paces the app-scroll coast (flywheel).
|
|
350
|
+
const lastOutputAtRef = useRef(0);
|
|
349
351
|
// Hold-space voice, same controller as the full screen — the tile is where
|
|
350
352
|
// typing mostly happens now, and dictation not existing here was most of
|
|
351
353
|
// "voice doesn't always work".
|
|
@@ -653,6 +655,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
653
655
|
box.addEventListener("pointercancel", endPan, true);
|
|
654
656
|
|
|
655
657
|
const detachFlywheel = attachScrollFlywheel(box, () => termRef.current as never, {
|
|
658
|
+
lastOutputAt: () => lastOutputAtRef.current,
|
|
656
659
|
linesPerNotch: 4,
|
|
657
660
|
lineHeightPx: () => Math.max(1, ((term as never as { _core?: { _renderService?: { dimensions?: { css?: { cell?: { height?: number } } } } } })._core
|
|
658
661
|
?._renderService?.dimensions?.css?.cell?.height) || PREVIEW_BASE_FS * 1.3)
|
|
@@ -906,6 +909,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
906
909
|
? m.keyboardEnhanced
|
|
907
910
|
: scanKeyboardProtocol(String(m.data || ""), false);
|
|
908
911
|
} else if (m.type === "output") {
|
|
912
|
+
lastOutputAtRef.current = performance.now();
|
|
909
913
|
term.write(m.data);
|
|
910
914
|
kbdEnhancedRef.current = scanKeyboardProtocol(String(m.data || ""), kbdEnhancedRef.current);
|
|
911
915
|
} else if (m.type === "active_size") {
|
|
@@ -59,6 +59,14 @@ export type FlywheelOptions = {
|
|
|
59
59
|
linesPerNotch: number;
|
|
60
60
|
/** Pixel height of one terminal row (for pixel-mode delta → lines). */
|
|
61
61
|
lineHeightPx: () => number;
|
|
62
|
+
/**
|
|
63
|
+
* Timestamp of the last output received from the session, when the caller
|
|
64
|
+
* has one. Paces app-owned coasting: a tracking app repaints per wheel
|
|
65
|
+
* step and each step costs a network round trip — a fixed-rate coast
|
|
66
|
+
* outruns a distant app and queues repaints, which reads as scroll LAG.
|
|
67
|
+
* With this signal each synthetic step waits for evidence the app kept up.
|
|
68
|
+
*/
|
|
69
|
+
lastOutputAt?: () => number;
|
|
62
70
|
};
|
|
63
71
|
|
|
64
72
|
/**
|
|
@@ -140,6 +148,7 @@ export const attachScrollFlywheel = (
|
|
|
140
148
|
}
|
|
141
149
|
};
|
|
142
150
|
|
|
151
|
+
let lastStepAt = 0;
|
|
143
152
|
const appGlide = (t: number) => {
|
|
144
153
|
appRaf = 0;
|
|
145
154
|
const term = getTerm();
|
|
@@ -147,11 +156,21 @@ export const attachScrollFlywheel = (
|
|
|
147
156
|
if (!term || terminalOwnsScrolling(term) || Math.abs(appVel) < 4) return stopApp();
|
|
148
157
|
const dt = lastFrameAt ? Math.min(0.05, (t - lastFrameAt) / 1000) : 0.016;
|
|
149
158
|
lastFrameAt = t;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
159
|
+
// ACK pacing: after each synthetic step, wait for the app to answer
|
|
160
|
+
// (any output) before spending the next one — the coast then runs at
|
|
161
|
+
// the link's real rhythm instead of flooding a slow one. 300ms cap so
|
|
162
|
+
// an app that repaints invisibly (no bytes for us) can't stall the
|
|
163
|
+
// glide forever.
|
|
164
|
+
const ackAt = opts.lastOutputAt ? opts.lastOutputAt() : Infinity;
|
|
165
|
+
const waitingForAck = lastStepAt > 0 && ackAt < lastStepAt && performance.now() - lastStepAt < 300;
|
|
166
|
+
if (!waitingForAck) {
|
|
167
|
+
appCarry += Math.abs(appVel) * dt;
|
|
168
|
+
let steps = Math.trunc(appCarry);
|
|
169
|
+
appCarry -= steps;
|
|
170
|
+
if (steps > 0) lastStepAt = performance.now();
|
|
171
|
+
while (steps-- > 0) dispatchStep();
|
|
172
|
+
appVel *= Math.exp(-dt / 0.35);
|
|
173
|
+
}
|
|
155
174
|
appRaf = requestAnimationFrame(appGlide);
|
|
156
175
|
};
|
|
157
176
|
|