hop2 0.11.20 → 0.11.22

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.
Files changed (33) hide show
  1. package/hay/apps/server/src/rooms.ts +9 -7
  2. package/hay/apps/web/src/App.tsx +57 -4
  3. package/hay/apps/web/src/components/SessionSwitcher.tsx +41 -12
  4. package/hay/apps/web/src/utils/scrollFlywheel.ts +177 -20
  5. package/hay/apps/web/src/utils/switcherModel.ts +7 -1
  6. package/hay/apps/web/test/SessionSwitcher.test.tsx +33 -0
  7. package/hay/apps/web/test/scrollFlywheel.test.ts +212 -0
  8. package/hay/apps/web/test/switcherModel.test.ts +25 -0
  9. package/hay-web/assets/index-CIfGRbUF.js +134 -0
  10. package/hay-web/assets/index-CIfGRbUF.js.map +1 -0
  11. package/hay-web/assets/index-COQpy7gK.js +134 -0
  12. package/hay-web/assets/index-COQpy7gK.js.map +1 -0
  13. package/hay-web/assets/index-CXZrhZBl.js +134 -0
  14. package/hay-web/assets/index-CXZrhZBl.js.map +1 -0
  15. package/hay-web/assets/index-Chtgn1Ot.js +134 -0
  16. package/hay-web/assets/index-Chtgn1Ot.js.map +1 -0
  17. package/hay-web/assets/index-DcF33Fu1.js +134 -0
  18. package/hay-web/assets/index-DcF33Fu1.js.map +1 -0
  19. package/hay-web/assets/index-DjSB7l7z.js +134 -0
  20. package/hay-web/assets/index-DjSB7l7z.js.map +1 -0
  21. package/hay-web/assets/index-JN4pNZh2.js +134 -0
  22. package/hay-web/assets/index-JN4pNZh2.js.map +1 -0
  23. package/hay-web/assets/index-tyqviMzG.js +134 -0
  24. package/hay-web/assets/index-tyqviMzG.js.map +1 -0
  25. package/hay-web/index.html +1 -1
  26. package/hop +370 -51
  27. package/hopa +2 -0
  28. package/mcp/hop-mcp.js +31 -0
  29. package/package.json +1 -1
  30. package/scripts/hay-host.js +32 -1
  31. package/scripts/salvage-and-restart-host.js +6 -1
  32. package/skills/hop-session/SKILL.md +25 -1
  33. package/tests/terminal-api.integration.test.cjs +129 -27
@@ -73,15 +73,17 @@ export type RoomSummary = {
73
73
  localCliCount: number;
74
74
  };
75
75
 
76
- // Raw output retained for reattach snapshots. ~20MB of raw stream reconstructs
77
- // roughly a full client scrollback (tens of thousands of lines) of plain
78
- // output; TUI streams are escape-dense and reconstruct less. This is the whole
79
- // buffer replayed (and parsed by the client) on reattach, so very large values
80
- // trade memory + reattach-parse time for deeper restored history. Override with
81
- // HAY_SNAPSHOT_BUFFER_BYTES (bytes of raw stream kept per room).
76
+ // Raw output retained for reattach snapshots. Sized for GC, not just
77
+ // features: rings live on the V8 heap as strings, and at 20MB x 24 rooms the
78
+ // host carried a ~350MB live heap whose major GCs stop the world 3-4s —
79
+ // measured on the fleet (2026-08-17: 340 event-loop stalls in a day, every
80
+ // one freezing typing everywhere; MarkCompact dominated the CPU sample).
81
+ // 6MB still exceeds every reader: the deep-history replay asks 1.5MB, the
82
+ // serialized attach reads the grid, and `hop restore` persists 64KB tails.
83
+ // Override with HAY_SNAPSHOT_BUFFER_BYTES for deeper rings at GC's expense.
82
84
  const MAX_BUFFER_SIZE = (() => {
83
85
  const env = Number(process.env.HAY_SNAPSHOT_BUFFER_BYTES);
84
- return Number.isFinite(env) && env > 0 ? env : 20_000_000;
86
+ return Number.isFinite(env) && env > 0 ? env : 6_000_000;
85
87
  })();
86
88
  // Keep rooms alive indefinitely; only explicit kill/remove should end a session.
87
89
  const CLEANUP_DELAY_MS = 0;
@@ -366,6 +366,9 @@ const App = () => {
366
366
  return null;
367
367
  });
368
368
  const [sessionLabel, setSessionLabel] = useState(() => initialRoom);
369
+ // The room's invariant id, as the server reported it in `hello` — the
370
+ // session.room string is just the display name the page was opened with.
371
+ const [canonicalRoomId, setCanonicalRoomId] = useState<string | null>(null);
369
372
  const [liveCwd, setLiveCwd] = useState<string | null>(null);
370
373
  const [status, setStatus] = useState<ConnectionStatus>("idle");
371
374
  // What the CHROME shows. Most drops (a tunnel restart, a laptop waking, an
@@ -495,11 +498,19 @@ const App = () => {
495
498
  return DEFAULT_SESSION_SWITCH_MODE;
496
499
  });
497
500
 
501
+ // The label is DERIVED, never copied: the room key is the session's
502
+ // invariant id, which equals the display name only until the first rename.
503
+ // This effect used to write the room key straight into the label, so the
504
+ // footer (and tab title, share link, bell notification) showed the
505
+ // pre-rename name forever on any renamed session. The list refresh is the
506
+ // source of truth; session_renamed messages update it instantly in between.
498
507
  useEffect(() => {
499
- if (session?.room) {
500
- setSessionLabel(session.room);
501
- }
502
- }, [session?.room]);
508
+ if (!session?.room) return;
509
+ const entry = sessions.find((s) =>
510
+ (s.internalName || s.name) === (canonicalRoomId || session.room)
511
+ || (s.internalName || s.name) === session.room);
512
+ setSessionLabel(entry ? (entry.displayName || entry.name) : session.room);
513
+ }, [session?.room, sessions, canonicalRoomId]);
503
514
  const [fontSize, setFontSize] = useState(() => {
504
515
  const saved = localStorage.getItem("hay_font_size");
505
516
  return saved ? parseInt(saved, 10) : 14;
@@ -1001,6 +1012,41 @@ const App = () => {
1001
1012
  catch { return []; }
1002
1013
  })());
1003
1014
 
1015
+ // ── Stale-bundle self-healing ─────────────────────────────────────────
1016
+ // Deploys replace the hashed bundle, but an open tab reconnects forever on
1017
+ // the code it loaded — every fix shipped "didn't work" until each tab was
1018
+ // manually reloaded (the scroll dead-zone report was a tab three bundles
1019
+ // behind). Poll our own index.html for the bundle it now names; when it
1020
+ // differs from the one running, reload — but only while the tab is HIDDEN
1021
+ // and the terminal has no buffered input, so nobody ever sees it happen.
1022
+ const staleBundleRef = useRef(false);
1023
+ useEffect(() => {
1024
+ let disposed = false;
1025
+ const loadedBundle = (() => {
1026
+ try { return new URL(import.meta.url).pathname.split("/").pop() || null; }
1027
+ catch { return null; }
1028
+ })();
1029
+ if (!loadedBundle) return;
1030
+ const check = async () => {
1031
+ try {
1032
+ const res = await fetch("/", { cache: "no-store" });
1033
+ const html = await res.text();
1034
+ const current = (html.match(/assets\/(index-[^"']+\.js)/) || [])[1];
1035
+ if (!disposed && current && current !== loadedBundle) staleBundleRef.current = true;
1036
+ } catch { /* offline — try again next tick */ }
1037
+ };
1038
+ const maybeReload = () => {
1039
+ if (staleBundleRef.current && document.hidden) window.location.reload();
1040
+ };
1041
+ const interval = window.setInterval(() => { void check().then(maybeReload); }, 5 * 60 * 1000);
1042
+ document.addEventListener("visibilitychange", maybeReload);
1043
+ return () => {
1044
+ disposed = true;
1045
+ window.clearInterval(interval);
1046
+ document.removeEventListener("visibilitychange", maybeReload);
1047
+ };
1048
+ }, []);
1049
+
1004
1050
  useEffect(() => {
1005
1051
  activeSessionRoomRef.current = session?.room ?? null;
1006
1052
  const room = session?.room;
@@ -1510,6 +1556,8 @@ const App = () => {
1510
1556
 
1511
1557
  const connect = (nextSession: { name: string; room: string }) => {
1512
1558
  const targetRoom = nextSession.room;
1559
+ // A new target invalidates the previous room's id; hello re-supplies it.
1560
+ setCanonicalRoomId(null);
1513
1561
  // Clear any pending reconnect
1514
1562
  if (reconnectTimerRef.current) {
1515
1563
  window.clearTimeout(reconnectTimerRef.current);
@@ -1652,6 +1700,11 @@ const App = () => {
1652
1700
  setClientId(message.clientId);
1653
1701
  setCollabMode(message.collabMode);
1654
1702
  setControllerId(message.controllerId);
1703
+ // The server names the room by its INVARIANT id here. The page
1704
+ // itself only knows the display name it was opened under, which
1705
+ // goes stale on the first rename — this is the durable key the
1706
+ // label derivation matches the session list against.
1707
+ if (message.roomId) setCanonicalRoomId(message.roomId);
1655
1708
  break;
1656
1709
  case "presence":
1657
1710
  // Presence broadcasts arrive on every peer keystroke (typing
@@ -2361,30 +2361,56 @@ export const SessionSwitcher = ({
2361
2361
  headers: { "Content-Type": "application/json" },
2362
2362
  body: JSON.stringify({ name: next, type: "terminal", port: null })
2363
2363
  });
2364
- const data = await res.json().catch(() => ({} as { name?: string; error?: string }));
2364
+ const data = await res.json().catch(() => ({} as { name?: string; displayName?: string; internalName?: string; error?: string }));
2365
2365
  if (!res.ok || !data.name) {
2366
2366
  // A name collision is the common failure, and "already in use" alone
2367
2367
  // leaves the user hunting: the holder may be filed in another folder,
2368
2368
  // or be an agent-created session they never opened. Name it, say
2369
2369
  // where it lives, and put it on screen — the answer to "it doesn't
2370
2370
  // work" should be the session that was in the way.
2371
+ // Search the WHOLE fleet, not the visible wall: the holder may be
2372
+ // hidden — parked, or an agent-created session on the other tab
2373
+ // (the mybot incident: create "failed", the explanation was a
2374
+ // session the user could not see). A silent 409 with an invisible
2375
+ // cause reads as "it just doesn't work"; the answer must be the
2376
+ // session that was in the way, revealed.
2371
2377
  const taken = res.status === 409
2372
- ? wallSessions.find((s) => (s.displayName || s.name).toLowerCase() === next.toLowerCase())
2378
+ ? sessions.find((s) => (s.displayName || s.name).toLowerCase() === next.toLowerCase())
2373
2379
  : null;
2374
2380
  if (taken) {
2375
2381
  const home = folders.find((f) => f.id === taken.folderId);
2376
- onNotice(`"${taken.displayName || taken.name}" already exists${home ? ` in ${home.name}` : ""} — showing it`);
2382
+ const visible = wallSessions.some((s) => sessionKey(s) === sessionKey(taken));
2383
+ const hiddenByOrigin = !visibleSessions.some((s) => sessionKey(s) === sessionKey(taken));
2384
+ const where = taken.parked
2385
+ ? " — it was hidden among the parked sessions"
2386
+ : hiddenByOrigin
2387
+ ? " — it was hidden on the Agent sessions tab"
2388
+ : home ? ` in ${home.name}` : "";
2389
+ onNotice(`"${taken.displayName || taken.name}" already exists${where} — showing it`);
2377
2390
  setCreating(false);
2378
2391
  setCreateDraft("");
2379
2392
  setCreateInFolder(null);
2393
+ // Reveal before focusing: flip whatever filter is hiding it.
2394
+ if (hiddenByOrigin) setOriginScope("all");
2395
+ if (taken.parked) setParkedOpen(true);
2380
2396
  const key = sessionKey(taken);
2381
2397
  setFocusedKey(key);
2382
- const idx = navIndexByKey.get(key);
2383
- if (typeof idx === "number") {
2384
- setKbdIndex(idx);
2385
- requestAnimationFrame(() => {
2386
- document.querySelector(`[data-nav-index="${idx}"]`)?.scrollIntoView({ block: "center" });
2387
- });
2398
+ if (visible) {
2399
+ const idx = navIndexByKey.get(key);
2400
+ if (typeof idx === "number") {
2401
+ setKbdIndex(idx);
2402
+ requestAnimationFrame(() => {
2403
+ document.querySelector(`[data-nav-index="${idx}"]`)?.scrollIntoView({ block: "center" });
2404
+ });
2405
+ }
2406
+ } else {
2407
+ // It enters the wall on the NEXT render (scope/park state just
2408
+ // changed); scroll once the card exists.
2409
+ window.setTimeout(() => {
2410
+ const el = document.querySelector(`[data-session-key="${CSS.escape(key)}"]`)
2411
+ || document.querySelector(`[data-nav-index]`);
2412
+ el?.scrollIntoView({ block: "center" });
2413
+ }, 120);
2388
2414
  }
2389
2415
  return;
2390
2416
  }
@@ -2399,7 +2425,7 @@ export const SessionSwitcher = ({
2399
2425
  await fetch("/api/sessions/move", {
2400
2426
  method: "POST",
2401
2427
  headers: { "Content-Type": "application/json" },
2402
- body: JSON.stringify({ internalName: data.name, folderId: createInFolder.id })
2428
+ body: JSON.stringify({ internalName: data.internalName || data.name, folderId: createInFolder.id })
2403
2429
  }).catch(() => { /* filing is best-effort; the session exists */ });
2404
2430
  setCreateInFolder(null);
2405
2431
  }
@@ -2407,8 +2433,10 @@ export const SessionSwitcher = ({
2407
2433
  // the exact accidental mode-entry the hotkey inversion removed from
2408
2434
  // clicks. The new tile becomes current and goes live in place as soon
2409
2435
  // as the session reports active.
2410
- onFocusSession?.({ name: data.name, displayName: data.name, active: false, starting: true, internalName: data.name });
2411
- setFocusedKey(data.name);
2436
+ // The id is minted server-side now; the display name is what the user
2437
+ // typed. Keys ride on the id, labels on the name.
2438
+ onFocusSession?.({ name: data.displayName || data.name, displayName: data.displayName || data.name, active: false, starting: true, internalName: data.internalName || data.name });
2439
+ setFocusedKey(data.internalName || data.name);
2412
2440
  onRefresh();
2413
2441
  } catch {
2414
2442
  onNotice("Failed to create session");
@@ -2491,6 +2519,7 @@ export const SessionSwitcher = ({
2491
2519
  role="button"
2492
2520
  tabIndex={0}
2493
2521
  data-nav-index={navIndexByKey.get(key)}
2522
+ data-session-key={key}
2494
2523
  className={`switcher-card${current ? " current" : ""}${activeNow ? " active-now" : ""}${kbdSelected ? " kbd-selected" : ""}${focusedKey === key ? " focused" : ""}${dragKey === key ? " dragging" : ""}${draggable ? " draggable" : ""}`}
2495
2524
  draggable={draggable}
2496
2525
  onDragStart={draggable ? (e) => { setDragKey(key); e.dataTransfer.effectAllowed = "move"; } : undefined}
@@ -9,10 +9,19 @@
9
9
  // trackpad feel.
10
10
  //
11
11
  // Safety rules (why this is a util and not three lines):
12
- // - Normal buffer only. On the alternate screen xterm translates wheel to
13
- // arrow keys / mouse reports for the app — inertia there would spray
14
- // keystrokes into vim/claude/less after the user stopped scrolling.
15
- // - Discrete wheels only (see isDiscreteWheel) — trackpads keep native feel.
12
+ // - Normal buffer only for LOCAL momentum. On the alternate screen xterm
13
+ // translates wheel to arrow keys / mouse reports for the app — local
14
+ // inertia there would spray keystrokes into vim/claude/less after the
15
+ // user stopped scrolling.
16
+ // - In LOCAL scrollback, discrete wheels only (see isDiscreteWheel) —
17
+ // trackpads keep the OS's native glide; doubling it felt drunk.
18
+ // - In APP-OWNED scrolling the rule inverts for trackpads: the OS momentum
19
+ // stream is hundreds of events, each of which xterm turns into a wire
20
+ // mouse-report costing a network round trip. Forwarded 1:1 it floods a
21
+ // remote app into scroll LAG (the transcript keeps moving long after the
22
+ // fingers stop). So there we take ownership: coalesce the stream into
23
+ // line-sized steps and pace them by the app's own acknowledgements —
24
+ // instant when local, matched to the link when remote.
16
25
  // - A single notch never glides: momentum engages only for a sustained spin
17
26
  // (3+ events in a burst), so precise one-line nudges stay precise.
18
27
  // - Any keypress or click kills the glide instantly, and so does hitting
@@ -107,7 +116,17 @@ export const attachScrollFlywheel = (
107
116
  // wire format — whatever it sends for a real notch is exactly what it sends
108
117
  // for ours.
109
118
  const APP_STEPS_PER_NOTCH = 3;
110
- const APP_MAX_RATE = 90; // steps/sec — fast, still ordered
119
+ const APP_MAX_RATE = 70; // steps/sec — fast, still ordered
120
+ // Remote scrolling physics: every step is a round trip (report up, full
121
+ // repaint down) and the app repaints ONCE per input batch it drains. So:
122
+ // batch lines per dispatch in proportion to speed (one repaint moves
123
+ // several lines instead of one), and keep a small window of unacked
124
+ // bursts in flight to hide the RTT — serialized one-line-per-RTT was the
125
+ // jerk. Slow speeds still step one line per burst: precision unharmed.
126
+ const APP_ACK_WINDOW = 2; // bursts in flight before we hold
127
+ const APP_MAX_BURST = 5; // lines per burst at full speed
128
+ const burstFor = (vel: number) =>
129
+ Math.max(1, Math.min(APP_MAX_BURST, Math.round(Math.abs(vel) / 20)));
111
130
  let appVel = 0;
112
131
  let appCarry = 0;
113
132
  let appRaf = 0;
@@ -125,6 +144,9 @@ export const attachScrollFlywheel = (
125
144
  appVel = 0;
126
145
  appCarry = 0;
127
146
  appBurst = 0;
147
+ panPx = 0;
148
+ panPending = 0;
149
+ unacked = 0;
128
150
  };
129
151
 
130
152
  const stop = () => { stopLocal(); stopApp(); };
@@ -149,11 +171,24 @@ export const attachScrollFlywheel = (
149
171
  };
150
172
 
151
173
  let lastStepAt = 0;
174
+ let unacked = 0; // bursts dispatched with no output seen since
152
175
  const appGlide = (t: number) => {
153
176
  appRaf = 0;
154
177
  const term = getTerm();
155
178
  // Mode flipped (app exited, screen switched) or run out of speed.
156
- if (!term || terminalOwnsScrolling(term) || Math.abs(appVel) < 4) return stopApp();
179
+ if (!term || terminalOwnsScrolling(term)) return stopApp();
180
+ if (panPending === 0 && Math.abs(appVel) < coastMin()) {
181
+ // Out of momentum. Rest the machinery but PRESERVE the sub-line
182
+ // accumulation in every case: the quantum is one whole line, and a
183
+ // slow precise drag only crosses it across many events and pauses —
184
+ // any wipe on this path is a low-speed dead zone (shipped one twice).
185
+ // panPx dies only on direction flip, a 2s-still gesture break, a
186
+ // keypress/click kill, or dispose.
187
+ appRaf = 0;
188
+ appVel = 0;
189
+ appCarry = 0;
190
+ return;
191
+ }
157
192
  const dt = lastFrameAt ? Math.min(0.05, (t - lastFrameAt) / 1000) : 0.016;
158
193
  lastFrameAt = t;
159
194
  // ACK pacing: after each synthetic step, wait for the app to answer
@@ -161,20 +196,128 @@ export const attachScrollFlywheel = (
161
196
  // the link's real rhythm instead of flooding a slow one. 300ms cap so
162
197
  // an app that repaints invisibly (no bytes for us) can't stall the
163
198
  // glide forever.
199
+ // Windowed ACK pacing: any output since the last burst clears the
200
+ // window (the app is keeping up); otherwise up to APP_ACK_WINDOW bursts
201
+ // may be in flight before we hold. The 300ms cap keeps an app that
202
+ // repaints invisibly (no bytes for us) from stalling the glide forever.
164
203
  const ackAt = opts.lastOutputAt ? opts.lastOutputAt() : Infinity;
165
- const waitingForAck = lastStepAt > 0 && ackAt < lastStepAt && performance.now() - lastStepAt < 300;
204
+ if (ackAt > lastStepAt) unacked = 0;
205
+ const waitingForAck = lastStepAt > 0 && unacked >= APP_ACK_WINDOW
206
+ && performance.now() - lastStepAt < 300;
166
207
  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);
208
+ if (panPending !== 0) {
209
+ // Finger-driven lines first: drain the coalesced pan queue in
210
+ // speed-proportional bursts — the app repaints once per burst, so
211
+ // a burst of 4 costs one round trip where 4 singles cost four.
212
+ const spend = Math.sign(panPending)
213
+ * Math.min(Math.abs(panPending), burstFor(appVel || panPending));
214
+ panPending -= spend;
215
+ if (spend !== 0) {
216
+ lastStepAt = performance.now();
217
+ unacked += 1;
218
+ if (appProto) appProto.deltaY = Math.sign(spend) * 120;
219
+ for (let i = 0; i < Math.abs(spend); i++) dispatchStep();
220
+ }
221
+ } else {
222
+ appCarry += Math.min(APP_MAX_RATE, Math.abs(appVel)) * dt;
223
+ const steps = Math.min(Math.trunc(appCarry), burstFor(appVel));
224
+ if (steps > 0) {
225
+ appCarry -= steps;
226
+ lastStepAt = performance.now();
227
+ unacked += 1;
228
+ if (appProto) appProto.deltaY = Math.sign(appVel) * 120;
229
+ for (let i = 0; i < steps; i++) dispatchStep();
230
+ }
231
+ appVel *= Math.exp(-dt / 0.24);
232
+ }
173
233
  }
174
234
  appRaf = requestAnimationFrame(appGlide);
175
235
  };
176
236
 
237
+ // ---- App-owned TRACKPAD panning -----------------------------------------
238
+ // The finger stream is coalesced into whole-line steps and drained by the
239
+ // same ACK-paced loop the wheel coast uses. panPx carries the sub-line
240
+ // remainder, so slow precise drags still land exactly one step as they
241
+ // cross each line boundary.
242
+ let panPx = 0;
243
+ let panPending = 0; // signed whole steps waiting to be dispatched
244
+ let panLastAt = 0;
245
+ let panMode = false; // last input was a finger stream, not a wheel
246
+ // Trackpads NEVER self-coast: macOS keeps emitting decaying momentum
247
+ // events after the fingers lift — that stream IS the coast, and it feeds
248
+ // the pan queue. Adding our own decay on top gave every flick two glides
249
+ // stacked, which read as overshoot. Only discrete wheels (no OS momentum)
250
+ // earn a synthetic coast, from 4 steps/s past their burst gate.
251
+ const coastMin = () => (panMode ? Infinity : 4);
252
+
253
+ const trackpadPan = (e: WheelEvent) => {
254
+ panMode = true;
255
+ appTarget = e.target;
256
+ // One line's worth of delta per synthetic event, in the pan's direction —
257
+ // xterm emits one wire report per event regardless of magnitude.
258
+ appProto = { deltaY: Math.sign(e.deltaY) * 120, clientX: e.clientX, clientY: e.clientY };
259
+
260
+ const now = e.timeStamp || performance.now();
261
+ const gap = now - panLastAt;
262
+ panLastAt = now;
263
+ const stepPx = Math.max(1, opts.lineHeightPx());
264
+ const px = e.deltaMode === 1 ? e.deltaY * stepPx : e.deltaY;
265
+ // Direction flip: dump everything queued the other way, instantly.
266
+ if (Math.sign(px) !== Math.sign(panPx + panPending) && (panPx !== 0 || panPending !== 0)) {
267
+ panPx = 0;
268
+ panPending = 0;
269
+ appVel = 0;
270
+ }
271
+ // The remainder SURVIVES slow pacing: a precise drag emits small
272
+ // deltas with long gaps, and wiping between them made low-speed
273
+ // scrolling move nothing at all (the quantum is one line; 3px events
274
+ // could never reach it). Only a genuinely new gesture — seconds of
275
+ // stillness — starts from zero.
276
+ if (gap > 2000) { panPx = 0; }
277
+ panPx += px;
278
+ const steps = Math.trunc(panPx / stepPx);
279
+ if (steps !== 0) {
280
+ panPx -= steps * stepPx;
281
+ panPending += steps;
282
+ }
283
+ // Velocity for the post-stream coast, in steps/sec, from the px rate.
284
+ const rate = (px / stepPx) / (Math.min(Math.max(gap, 8), 200) / 1000);
285
+ appVel = Math.sign(rate) === Math.sign(appVel) ? 0.5 * rate + 0.5 * appVel : rate;
286
+ appVel = Math.max(-APP_MAX_RATE, Math.min(APP_MAX_RATE, appVel));
287
+
288
+ // Leading edge dispatches INLINE: a precise first movement must not
289
+ // wait out an animation frame — latency is what precision feels like.
290
+ // The rAF loop takes over for everything after, where pacing matters.
291
+ if (panPending !== 0 && unacked < APP_ACK_WINDOW) {
292
+ const spend = Math.sign(panPending)
293
+ * Math.min(Math.abs(panPending), burstFor(appVel || panPending));
294
+ panPending -= spend;
295
+ lastStepAt = performance.now();
296
+ unacked += 1;
297
+ if (appProto) appProto.deltaY = Math.sign(spend) * 120;
298
+ for (let i = 0; i < Math.abs(spend); i++) dispatchStep();
299
+ }
300
+ if (!appRaf) {
301
+ lastFrameAt = 0;
302
+ appRaf = requestAnimationFrame(appGlide);
303
+ }
304
+ if (appIdle) window.clearTimeout(appIdle);
305
+ appIdle = window.setTimeout(() => {
306
+ appIdle = 0;
307
+ // The OS stream paused. Rest the velocity, but PRESERVE the sub-line
308
+ // remainder — this fires 120ms after every event of a slow drag, and
309
+ // sweeping panPx here was half of the low-speed dead zone. The next
310
+ // event continues the accumulation; the 2s gate above decides when a
311
+ // pause is actually a new gesture.
312
+ if (!appRaf && panPending === 0 && Math.abs(appVel) < coastMin()) {
313
+ appVel = 0;
314
+ appCarry = 0;
315
+ }
316
+ }, 120);
317
+ };
318
+
177
319
  const appWheel = (e: WheelEvent) => {
320
+ panMode = false;
178
321
  appTarget = e.target;
179
322
  appProto = { deltaY: e.deltaY, clientX: e.clientX, clientY: e.clientY };
180
323
  if (appRaf) { cancelAnimationFrame(appRaf); appRaf = 0; }
@@ -220,7 +363,7 @@ export const attachScrollFlywheel = (
220
363
  term.scrollLines(lines);
221
364
  if (term.buffer.active.viewportY === before) return stop(); // hit an edge
222
365
  }
223
- vel *= Math.exp(-dt / 0.35); // ~350ms decay time-constant
366
+ vel *= Math.exp(-dt / 0.24); // ~240ms decay: brisk, not a runaway
224
367
  raf = requestAnimationFrame(glide);
225
368
  };
226
369
 
@@ -236,8 +379,20 @@ export const attachScrollFlywheel = (
236
379
  // still does all the scrolling; we only shape the event rate.
237
380
  if (!terminalOwnsScrolling(term)) {
238
381
  stopLocal();
239
- if (isDiscreteWheel(e)) appWheel(e);
240
- else stopApp(); // trackpad: the OS already sends a dense glide
382
+ // Pinch-zoom and sideways pans are not scrolling — leave them alone.
383
+ if (e.ctrlKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
384
+ if (isDiscreteWheel(e)) {
385
+ appWheel(e);
386
+ } else {
387
+ // Trackpad: take the stream over entirely. Without this, xterm
388
+ // forwards every one of the OS's momentum events as its own wire
389
+ // report and a remote app drowns (scroll lag, overshoot). We
390
+ // preventDefault + stopPropagation the raw events and re-emit
391
+ // line-sized, ACK-paced synthetic ones instead.
392
+ e.preventDefault();
393
+ e.stopPropagation();
394
+ trackpadPan(e);
395
+ }
241
396
  return;
242
397
  }
243
398
  stopApp();
@@ -255,7 +410,7 @@ export const attachScrollFlywheel = (
255
410
  const rate = notchLines / (Math.min(Math.max(gap, 30), 200) / 1000);
256
411
  // Same direction: blend toward the new rate. Direction flip: hard reset.
257
412
  vel = Math.sign(rate) === Math.sign(vel) ? 0.6 * rate + 0.4 * vel : rate;
258
- const CAP = 2500; // lines/s — fast, but never a teleport
413
+ const CAP = 1400; // lines/s — fast, but never a teleport
259
414
  vel = Math.max(-CAP, Math.min(CAP, vel));
260
415
 
261
416
  if (idleTimer) window.clearTimeout(idleTimer);
@@ -272,13 +427,15 @@ export const attachScrollFlywheel = (
272
427
  };
273
428
 
274
429
  const kill = () => stop();
275
- // CAPTURE phase, but strictly observational (passive: we never
276
- // preventDefault). Capture is REQUIRED: when the app is tracking the
430
+ // CAPTURE phase. Capture is REQUIRED: when the app is tracking the
277
431
  // mouse, xterm's own handler calls stopPropagation() on the way out, so a
278
432
  // bubble-phase listener on this ancestor never runs at all — which is why
279
433
  // the app-scroll shaping below silently did nothing. Observing first lets
280
434
  // xterm do exactly what it always did, and lets us add steps around it.
281
- el.addEventListener("wheel", onWheel, { capture: true, passive: true });
435
+ // capture (see below) and NON-passive: the app-owned trackpad branch
436
+ // preventDefaults the raw stream it replaces. Every other path still never
437
+ // cancels anything.
438
+ el.addEventListener("wheel", onWheel, { capture: true, passive: false });
282
439
  el.addEventListener("mousedown", kill, true);
283
440
  window.addEventListener("keydown", kill, true);
284
441
  return () => {
@@ -182,7 +182,13 @@ export const filterSessionsByOrigin = (
182
182
  ) => {
183
183
  if (scope === "all") return sessions;
184
184
  if (scope === "agent") return sessions.filter((session) => session.createdBy === "agent");
185
- return sessions.filter((session) => session.createdBy !== "agent");
185
+ // The user wall: everything not agent-made — PLUS anything filed in a
186
+ // folder. Folders are the user's own structure, and an agent only files a
187
+ // session into one because the user asked for it there ("create mybot
188
+ // under Softwares", relayed through an agent, landed createdBy=agent and
189
+ // VANISHED from the default wall — created, filed, invisible). Placement
190
+ // in user space outranks provenance.
191
+ return sessions.filter((session) => session.createdBy !== "agent" || !!session.folderId);
186
192
  };
187
193
 
188
194
  /**
@@ -357,3 +357,36 @@ describe("manual folders in the wall", () => {
357
357
  expect(screen.getByRole("button", { name: /Search full history for/ })).toBeTruthy();
358
358
  });
359
359
  });
360
+
361
+ describe("create colliding with a hidden session", () => {
362
+ afterEach(() => { vi.unstubAllGlobals(); });
363
+
364
+ it("explains the invisible holder and reveals it instead of failing mutely", async () => {
365
+ // The mybot incident, second act: the name is taken by a session the
366
+ // user cannot SEE (agent-created, loose, other tab). A bare "already in
367
+ // use" against an empty wall reads as "create silently did nothing".
368
+ const holder: SwitcherSession = {
369
+ name: "mybot", displayName: "mybot", internalName: "mybot",
370
+ active: true, starting: false, createdBy: "agent"
371
+ };
372
+ const onNotice = vi.fn();
373
+ vi.stubGlobal("fetch", vi.fn(async () => ({
374
+ ok: false,
375
+ status: 409,
376
+ json: async () => ({ error: "Session name already in use" })
377
+ })));
378
+
379
+ render(<SessionSwitcher {...props} onNotice={onNotice} sessions={[...sessions, holder]} open />);
380
+ // The holder is genuinely hidden on the default (user) tab.
381
+ expect(screen.queryByText("mybot")).toBeNull();
382
+
383
+ fireEvent.click(screen.getByRole("button", { name: "New session" }));
384
+ fireEvent.change(screen.getByLabelText("New session name"), { target: { value: "mybot" } });
385
+ fireEvent.submit(screen.getByLabelText("New session name").closest("form")!);
386
+ await new Promise((r) => setTimeout(r, 50));
387
+
388
+ expect(onNotice).toHaveBeenCalledWith(expect.stringMatching(/already exists.*hidden.*Agent/i));
389
+ // And the wall now SHOWS it — scope flipped so the answer is on screen.
390
+ expect(screen.getByText("mybot")).toBeTruthy();
391
+ });
392
+ });