hop2 0.11.4 → 0.11.5
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/apps/web/src/components/SessionSwitcher.tsx +229 -11
- package/hay/apps/web/src/styles.css +39 -4
- package/hay/apps/web/src/utils/scrollFlywheel.ts +140 -14
- package/hay/apps/web/src/utils/voiceHold.ts +13 -0
- package/hay/apps/web/test/claudeSurface.test.ts +44 -0
- package/hay-web/assets/{index-BRpBf8QE.css → index-BJnLaQd5.css} +1 -1
- package/hay-web/assets/index-CJHvzXqn.js +133 -0
- package/hay-web/assets/{index-isX6Rkr2.js.map → index-CJHvzXqn.js.map} +1 -1
- package/hay-web/index.html +2 -2
- package/package.json +1 -1
- package/hay-web/assets/index-isX6Rkr2.js +0 -132
|
@@ -15,7 +15,7 @@ import { WebglAddon } from "@xterm/addon-webgl";
|
|
|
15
15
|
import { attachScrollFlywheel } from "../utils/scrollFlywheel";
|
|
16
16
|
import { ContextMenu, type MenuRequest } from "./ContextMenu";
|
|
17
17
|
import { collectTerminalMatches, selectTerminalMatch } from "../utils/terminalSearch";
|
|
18
|
-
import {
|
|
18
|
+
import { createVoiceHold, isClaudeSurface } from "../utils/voiceHold";
|
|
19
19
|
import {
|
|
20
20
|
buildSwitcherModel,
|
|
21
21
|
filterSessionsByOrigin,
|
|
@@ -162,7 +162,11 @@ const PREVIEW_BASE_LH = PREVIEW_BASE_FS * 1.3;
|
|
|
162
162
|
let previewCharW = 0;
|
|
163
163
|
const advanceCache = new Map<number, number>();
|
|
164
164
|
if (typeof document !== "undefined") {
|
|
165
|
-
document.fonts?.ready?.then(() => {
|
|
165
|
+
document.fonts?.ready?.then(() => {
|
|
166
|
+
previewCharW = 0;
|
|
167
|
+
advanceCache.clear();
|
|
168
|
+
charHeightCache.clear();
|
|
169
|
+
}).catch(() => { /* no Font API */ });
|
|
166
170
|
}
|
|
167
171
|
const measureAdvanceAt = (fs: number) => {
|
|
168
172
|
const hit = advanceCache.get(fs);
|
|
@@ -196,6 +200,33 @@ const tileLetterSpacing = (fs: number) => {
|
|
|
196
200
|
return Math.floor(adv * dpr) / dpr - adv;
|
|
197
201
|
};
|
|
198
202
|
|
|
203
|
+
// Row height, the vertical half of the same story. xterm derives it from the
|
|
204
|
+
// FONT'S natural line box (line-height:normal), not from fontSize — at 12px
|
|
205
|
+
// Menlo that is 14px, so xterm's row is 18px while `fontSize * 1.3` says
|
|
206
|
+
// 15.6. The preview was ~15% short per row: bottom-anchored, so the newest
|
|
207
|
+
// lines looked right and everything above crept — the residual
|
|
208
|
+
// preview → terminal shift on sessions with content up the screen.
|
|
209
|
+
// Rule (probe-measured, exact at every size and dpr):
|
|
210
|
+
// row = floor(naturalCharHeight × lineHeight × dpr) / dpr
|
|
211
|
+
const charHeightCache = new Map<number, number>();
|
|
212
|
+
const measureCharHeightAt = (fs: number) => {
|
|
213
|
+
const hit = charHeightCache.get(fs);
|
|
214
|
+
if (hit) return hit;
|
|
215
|
+
const el = document.createElement("div");
|
|
216
|
+
el.style.cssText = `position:absolute;visibility:hidden;white-space:pre;line-height:normal;font-size:${fs}px`;
|
|
217
|
+
el.style.fontFamily = "var(--font-terminal)";
|
|
218
|
+
el.textContent = "W";
|
|
219
|
+
document.body.appendChild(el);
|
|
220
|
+
const h = el.getBoundingClientRect().height || fs * 1.2;
|
|
221
|
+
el.remove();
|
|
222
|
+
charHeightCache.set(fs, h);
|
|
223
|
+
return h;
|
|
224
|
+
};
|
|
225
|
+
const tileRowHeight = (fs: number) => {
|
|
226
|
+
const dpr = (typeof window !== "undefined" && window.devicePixelRatio) || 1;
|
|
227
|
+
return Math.floor(measureCharHeightAt(fs) * 1.3 * dpr) / dpr;
|
|
228
|
+
};
|
|
229
|
+
|
|
199
230
|
// ONE font for the whole wall at a given zoom, derived from the box width —
|
|
200
231
|
// never from any session's geometry. Both live tiles and static previews use
|
|
201
232
|
// this, which is what makes the wall read as one surface instead of a
|
|
@@ -212,9 +243,11 @@ const previewScaleObserver = typeof ResizeObserver !== "undefined"
|
|
|
212
243
|
const inner = box.firstElementChild as HTMLElement | null;
|
|
213
244
|
if (!inner) continue;
|
|
214
245
|
const fs2 = tileFontFor(box.clientWidth);
|
|
246
|
+
const rowH = tileRowHeight(fs2);
|
|
215
247
|
inner.style.fontSize = fs2 + "px";
|
|
216
|
-
inner.style.lineHeight =
|
|
248
|
+
inner.style.lineHeight = rowH + "px";
|
|
217
249
|
inner.style.letterSpacing = tileLetterSpacing(fs2) + "px";
|
|
250
|
+
inner.style.setProperty("--tile-row-h", rowH + "px");
|
|
218
251
|
}
|
|
219
252
|
})
|
|
220
253
|
: null;
|
|
@@ -239,7 +272,12 @@ const ScaledScreen = ({ frame }: { frame: PreviewFrame }) => {
|
|
|
239
272
|
<div className="switcher-preview-scalebox" ref={boxRef}>
|
|
240
273
|
<div
|
|
241
274
|
className="switcher-preview-screen"
|
|
242
|
-
style={{
|
|
275
|
+
style={{
|
|
276
|
+
fontSize: PREVIEW_BASE_FS,
|
|
277
|
+
lineHeight: `${tileRowHeight(PREVIEW_BASE_FS)}px`,
|
|
278
|
+
letterSpacing: `${tileLetterSpacing(PREVIEW_BASE_FS)}px`,
|
|
279
|
+
["--tile-row-h" as string]: `${tileRowHeight(PREVIEW_BASE_FS)}px`
|
|
280
|
+
} as React.CSSProperties}
|
|
243
281
|
>
|
|
244
282
|
{renderPreviewRuns(padded)}
|
|
245
283
|
</div>
|
|
@@ -312,16 +350,20 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
312
350
|
// "voice doesn't always work".
|
|
313
351
|
const [voiceInterim, setVoiceInterim] = useState<string | null>(null);
|
|
314
352
|
const voiceHoldRef = useRef<ReturnType<typeof createVoiceHold> | null>(null);
|
|
353
|
+
// Is this tile showing Claude Code? Decides KEY ENCODING (Shift+Enter's
|
|
354
|
+
// CSI-u) and voice eligibility, so it has to be right on restored
|
|
355
|
+
// sessions too. The `claudeApp` prop reads the session's foreground
|
|
356
|
+
// process, which an argv-launched restore reports as the wrapper shell
|
|
357
|
+
// FOREVER (`shell -lc "claude …; exec shell -l"`) — so it is false for
|
|
358
|
+
// every session that came back through `hop restore`. The buffer's own
|
|
359
|
+
// chrome is the honest witness; both callers share it.
|
|
360
|
+
const looksLikeClaude = () => isClaudeSurface(claudeAppRef.current, termRef.current as never);
|
|
315
361
|
if (!voiceHoldRef.current) {
|
|
316
362
|
voiceHoldRef.current = createVoiceHold({
|
|
317
363
|
send: (data) => sendInputRef.current?.(data),
|
|
318
364
|
notify: (m) => onNoticeRef.current?.(m),
|
|
319
365
|
setOverlay: setVoiceInterim,
|
|
320
|
-
eligible: () =>
|
|
321
|
-
if (claudeAppRef.current) return true;
|
|
322
|
-
const t = termRef.current;
|
|
323
|
-
return !!t && bufferLooksLikeClaude(t as never);
|
|
324
|
-
}
|
|
366
|
+
eligible: () => looksLikeClaude()
|
|
325
367
|
});
|
|
326
368
|
}
|
|
327
369
|
|
|
@@ -329,6 +371,16 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
329
371
|
// find bar. Scope follows focus — ⌘F in a live tile searches THIS session;
|
|
330
372
|
// ⌘F anywhere else is the wall filter, which finds sessions.
|
|
331
373
|
const [find, setFind] = useState<{ query: string; index: number; total: number } | null>(null);
|
|
374
|
+
// The session's grid does not fit this tile — someone else (a full-screen
|
|
375
|
+
// client, the CLI, another person) holds the size, and our claim was
|
|
376
|
+
// declined or never made. The tile then crops silently, which reads as a
|
|
377
|
+
// rendering bug rather than "this terminal is bigger than this box". Track
|
|
378
|
+
// the overflow so the tile can SAY so, and let the user pan to the rest.
|
|
379
|
+
const [offGrid, setOffGrid] = useState<{ x: number; y: number; cols: number; rows: number } | null>(null);
|
|
380
|
+
const offGridRef = useRef<{ x: number; y: number; cols: number; rows: number } | null>(null);
|
|
381
|
+
// User's pan offset within the oversized terminal (px, <= 0). Null = follow
|
|
382
|
+
// the automatic bottom anchor.
|
|
383
|
+
const panRef = useRef<{ x: number; y: number } | null>(null);
|
|
332
384
|
const findInputRef = useRef<HTMLInputElement | null>(null);
|
|
333
385
|
const findIndexRef = useRef(-1);
|
|
334
386
|
const runTileFind = (query: string, step = 0) => {
|
|
@@ -434,8 +486,54 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
434
486
|
requestAnimationFrame(() => rescaleRef.current());
|
|
435
487
|
return;
|
|
436
488
|
}
|
|
437
|
-
|
|
489
|
+
// BOTTOM-ANCHOR the live terminal, same rule as the preview.
|
|
490
|
+
//
|
|
491
|
+
// A session's grid rarely divides evenly into a tile: measured on the
|
|
492
|
+
// real wall, a 40-row session rendered 475px tall inside a 460px box,
|
|
493
|
+
// so the last row — Claude's composer, the newest output — sat below
|
|
494
|
+
// the clip and was simply invisible. Going live didn't help (identical
|
|
495
|
+
// geometry) and only full screen "fixed" it, by refitting. Autofit
|
|
496
|
+
// can't save it either: a one-row overflow is well inside the claim
|
|
497
|
+
// tolerance, so no resize is ever asked for and the crop is permanent.
|
|
498
|
+
// Translate so the LAST row is flush with the box bottom; overflow now
|
|
499
|
+
// crops the oldest rows off the top, which is what scrollback is for.
|
|
500
|
+
// Transform the SCREEN, not `.xterm`. `.xterm` sets overflow:hidden on
|
|
501
|
+
// itself, so translating it slides an already-clipped window: the rows
|
|
502
|
+
// outside never appear no matter how far you pan (and a rect-based
|
|
503
|
+
// measurement is fooled, because getBoundingClientRect reports clipped
|
|
504
|
+
// geometry as if visible — which is how the first version of this
|
|
505
|
+
// "fixed" the bottom crop on paper while the pixels stayed hidden).
|
|
506
|
+
// Moving the screen inside that clip is what actually reveals rows.
|
|
438
507
|
inner.style.transform = "";
|
|
508
|
+
screen.style.transformOrigin = "bottom left";
|
|
509
|
+
screen.style.transform = "";
|
|
510
|
+
const boxRect = inner.getBoundingClientRect(); // .xterm IS the clip
|
|
511
|
+
const screenRect = screen.getBoundingClientRect(); // reads after the reset above
|
|
512
|
+
const overflowX = Math.max(0, Math.round(screenRect.width - boxRect.width));
|
|
513
|
+
const overflowY = Math.max(0, Math.round(screenRect.height - boxRect.height));
|
|
514
|
+
// Auto anchor: last row flush with the box bottom, left edge shown.
|
|
515
|
+
const autoY = Math.round(boxRect.bottom - screenRect.bottom);
|
|
516
|
+
const pan = panRef.current;
|
|
517
|
+
// A pan is clamped to whatever is actually off-screen right now — the
|
|
518
|
+
// tile may have been resized since the user dragged.
|
|
519
|
+
const px = pan ? Math.max(-overflowX, Math.min(0, pan.x)) : 0;
|
|
520
|
+
const py = pan ? Math.max(autoY, Math.min(0, pan.y)) : autoY;
|
|
521
|
+
if (pan) panRef.current = { x: px, y: py };
|
|
522
|
+
if (px !== 0 || py !== 0) screen.style.transform = `translate(${px}px, ${py}px)`;
|
|
523
|
+
// Only publish on CHANGE: rescale runs from a ResizeObserver, and
|
|
524
|
+
// setting a fresh object every pass would re-render (and re-observe)
|
|
525
|
+
// in a loop.
|
|
526
|
+
const next = overflowX > 1 || overflowY > 1
|
|
527
|
+
? { x: overflowX, y: overflowY, cols: term.cols, rows: term.rows }
|
|
528
|
+
: null;
|
|
529
|
+
const prev = offGridRef.current;
|
|
530
|
+
const same = (!next && !prev)
|
|
531
|
+
|| (!!next && !!prev && next.x === prev.x && next.y === prev.y
|
|
532
|
+
&& next.cols === prev.cols && next.rows === prev.rows);
|
|
533
|
+
if (!same) {
|
|
534
|
+
offGridRef.current = next;
|
|
535
|
+
setOffGrid(next);
|
|
536
|
+
}
|
|
439
537
|
};
|
|
440
538
|
rescaleRef.current = rescale;
|
|
441
539
|
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(rescale) : null;
|
|
@@ -463,8 +561,14 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
463
561
|
}
|
|
464
562
|
return false;
|
|
465
563
|
}
|
|
564
|
+
// Shift+Enter → CSI 13;2u (newline in Claude's composer instead of
|
|
565
|
+
// submit). Claude parses CSI-u unconditionally but no longer
|
|
566
|
+
// advertises the kitty protocol at boot, so keyboardEnhanced is
|
|
567
|
+
// legitimately false — detection of Claude ITSELF is the gate, and it
|
|
568
|
+
// must survive a restore (see looksLikeClaude). A plain shell would
|
|
569
|
+
// render raw CSI-u as junk text, so the gate stays.
|
|
466
570
|
if (ev.key === "Enter" && ev.shiftKey && !ev.ctrlKey && !ev.metaKey && !ev.altKey
|
|
467
|
-
&& (kbdEnhancedRef.current ||
|
|
571
|
+
&& (kbdEnhancedRef.current || looksLikeClaude())) {
|
|
468
572
|
if (ev.type === "keydown") sendInputRef.current?.("\x1b[13;2u");
|
|
469
573
|
return false;
|
|
470
574
|
}
|
|
@@ -479,12 +583,79 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
479
583
|
return true;
|
|
480
584
|
});
|
|
481
585
|
const sub = term.onData((data) => sendInputRef.current?.(data));
|
|
586
|
+
// ⌥-drag pans an oversized terminal inside its tile. The modifier matters:
|
|
587
|
+
// a plain drag belongs to the session (text selection, or mouse reports
|
|
588
|
+
// to a tracking app like Claude), so panning must not steal it.
|
|
589
|
+
let panning: { sx: number; sy: number; ox: number; oy: number } | null = null;
|
|
590
|
+
const panBounds = () => {
|
|
591
|
+
const screen = box.querySelector(".xterm-screen") as HTMLElement | null;
|
|
592
|
+
const clip = box.querySelector(".xterm") as HTMLElement | null;
|
|
593
|
+
if (!screen || !clip) return null;
|
|
594
|
+
const boxRect = clip.getBoundingClientRect(); // same clip rescale uses
|
|
595
|
+
const scrRect = screen.getBoundingClientRect();
|
|
596
|
+
return {
|
|
597
|
+
overflowX: Math.max(0, scrRect.width - boxRect.width),
|
|
598
|
+
overflowY: Math.max(0, scrRect.height - boxRect.height),
|
|
599
|
+
autoY: Math.round(boxRect.bottom - scrRect.bottom)
|
|
600
|
+
};
|
|
601
|
+
};
|
|
602
|
+
const onPanDown = (e: PointerEvent) => {
|
|
603
|
+
if (!e.altKey || e.button !== 0) return;
|
|
604
|
+
const b = panBounds();
|
|
605
|
+
if (!b || (b.overflowX < 1 && b.overflowY < 1)) return;
|
|
606
|
+
const cur = panRef.current || { x: 0, y: b.autoY };
|
|
607
|
+
panRef.current = cur;
|
|
608
|
+
panning = { sx: e.clientX, sy: e.clientY, ox: cur.x, oy: cur.y };
|
|
609
|
+
try { box.setPointerCapture(e.pointerId); } catch { /* not captureable */ }
|
|
610
|
+
e.preventDefault();
|
|
611
|
+
e.stopPropagation();
|
|
612
|
+
};
|
|
613
|
+
const onPanMove = (e: PointerEvent) => {
|
|
614
|
+
if (!panning) return;
|
|
615
|
+
panRef.current = {
|
|
616
|
+
x: panning.ox + (e.clientX - panning.sx),
|
|
617
|
+
y: panning.oy + (e.clientY - panning.sy)
|
|
618
|
+
};
|
|
619
|
+
rescaleRef.current(); // clamps to the current overflow and repaints
|
|
620
|
+
e.preventDefault();
|
|
621
|
+
};
|
|
622
|
+
const endPan = (e: PointerEvent) => {
|
|
623
|
+
if (!panning) return;
|
|
624
|
+
panning = null;
|
|
625
|
+
try { box.releasePointerCapture(e.pointerId); } catch { /* already released */ }
|
|
626
|
+
};
|
|
627
|
+
// The natural gesture: a two-finger SIDEWAYS swipe pans a terminal that
|
|
628
|
+
// is wider than its tile. Horizontal wheel deltas are otherwise unused
|
|
629
|
+
// here (vertical belongs to scrolling the session), so this costs no
|
|
630
|
+
// other interaction and needs no modifier. Capture + non-passive because
|
|
631
|
+
// xterm would otherwise consume the event on a tracking app.
|
|
632
|
+
const onPanWheel = (e: WheelEvent) => {
|
|
633
|
+
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; // vertical = scroll
|
|
634
|
+
const b = panBounds();
|
|
635
|
+
if (!b || b.overflowX < 1) return;
|
|
636
|
+
const cur = panRef.current || { x: 0, y: b.autoY };
|
|
637
|
+
panRef.current = { x: cur.x - e.deltaX, y: cur.y };
|
|
638
|
+
rescaleRef.current();
|
|
639
|
+
e.preventDefault();
|
|
640
|
+
e.stopPropagation();
|
|
641
|
+
};
|
|
642
|
+
box.addEventListener("wheel", onPanWheel, { capture: true, passive: false });
|
|
643
|
+
box.addEventListener("pointerdown", onPanDown, true);
|
|
644
|
+
box.addEventListener("pointermove", onPanMove, true);
|
|
645
|
+
box.addEventListener("pointerup", endPan, true);
|
|
646
|
+
box.addEventListener("pointercancel", endPan, true);
|
|
647
|
+
|
|
482
648
|
const detachFlywheel = attachScrollFlywheel(box, () => termRef.current as never, {
|
|
483
649
|
linesPerNotch: 4,
|
|
484
650
|
lineHeightPx: () => Math.max(1, ((term as never as { _core?: { _renderService?: { dimensions?: { css?: { cell?: { height?: number } } } } } })._core
|
|
485
651
|
?._renderService?.dimensions?.css?.cell?.height) || PREVIEW_BASE_FS * 1.3)
|
|
486
652
|
});
|
|
487
653
|
return () => {
|
|
654
|
+
box.removeEventListener("wheel", onPanWheel, { capture: true } as EventListenerOptions);
|
|
655
|
+
box.removeEventListener("pointerdown", onPanDown, true);
|
|
656
|
+
box.removeEventListener("pointermove", onPanMove, true);
|
|
657
|
+
box.removeEventListener("pointerup", endPan, true);
|
|
658
|
+
box.removeEventListener("pointercancel", endPan, true);
|
|
488
659
|
detachFlywheel();
|
|
489
660
|
ro?.disconnect();
|
|
490
661
|
sub.dispose();
|
|
@@ -857,6 +1028,28 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
857
1028
|
{viewers.some((v) => /agent|hopa|\(pane\)/i.test(v.name)) && <b className="viewer-agent">AI</b>}
|
|
858
1029
|
</span>
|
|
859
1030
|
)}
|
|
1031
|
+
{offGrid && (
|
|
1032
|
+
// Say WHY the tile is cropped. Without this the session just
|
|
1033
|
+
// looks broken; with it, the size is visibly someone else's
|
|
1034
|
+
// choice, and both remedies are one gesture away.
|
|
1035
|
+
<button
|
|
1036
|
+
type="button"
|
|
1037
|
+
className="switcher-tile-offgrid"
|
|
1038
|
+
title={
|
|
1039
|
+
`This session's terminal is ${offGrid.cols}×${offGrid.rows}, larger than the tile`
|
|
1040
|
+
+ (driver ? ` — ${driver.name} is using it right now` : viewers.length ? " — another client holds the size" : "")
|
|
1041
|
+
+ ".\n⌥-drag to pan. Click to resize the session to fit this tile."
|
|
1042
|
+
}
|
|
1043
|
+
onClick={(e) => {
|
|
1044
|
+
e.stopPropagation();
|
|
1045
|
+
panRef.current = null; // back to the automatic anchor
|
|
1046
|
+
claimRef.current();
|
|
1047
|
+
}}
|
|
1048
|
+
>
|
|
1049
|
+
<span className="offgrid-glyph" aria-hidden="true">⤢</span>
|
|
1050
|
+
{offGrid.cols}×{offGrid.rows}
|
|
1051
|
+
</button>
|
|
1052
|
+
)}
|
|
860
1053
|
{find && (
|
|
861
1054
|
<span className="switcher-tile-find" onClick={(e) => e.stopPropagation()}>
|
|
862
1055
|
<input
|
|
@@ -1341,6 +1534,31 @@ export const SessionSwitcher = ({
|
|
|
1341
1534
|
flatNav.forEach((s, i) => m.set(sessionKey(s), i));
|
|
1342
1535
|
return m;
|
|
1343
1536
|
}, [flatNav]);
|
|
1537
|
+
// Opening the wall focuses the session the app is ON. A deep link like
|
|
1538
|
+
// /s/<name>/?view=wall names a session, so landing on the wall with
|
|
1539
|
+
// nothing focused (and the keyboard sitting on the FIRST card) loses the
|
|
1540
|
+
// one thing the URL asked for. The old name works too — the room id is
|
|
1541
|
+
// the internal name, so a renamed session still resolves. This is not an
|
|
1542
|
+
// "accidental" focus: arriving here is the user's explicit act, and it
|
|
1543
|
+
// focuses in place rather than entering full screen.
|
|
1544
|
+
useEffect(() => {
|
|
1545
|
+
if (!open || focusedKey || !currentRoom) return;
|
|
1546
|
+
const current = flatNav.find((s) => (s.internalName || s.name) === currentRoom || s.name === currentRoom);
|
|
1547
|
+
if (!current) return;
|
|
1548
|
+
const key = sessionKey(current);
|
|
1549
|
+
setFocusedKey(key);
|
|
1550
|
+
const idx = navIndexByKey.get(key);
|
|
1551
|
+
if (typeof idx === "number") {
|
|
1552
|
+
setKbdIndex(idx);
|
|
1553
|
+
// ...and bring it into view. A deep link can name a session sitting
|
|
1554
|
+
// far down a long wall; focusing a card the user cannot see is the
|
|
1555
|
+
// same as not focusing it. Deferred a frame so the card exists.
|
|
1556
|
+
requestAnimationFrame(() => {
|
|
1557
|
+
document.querySelector(`[data-nav-index="${idx}"]`)?.scrollIntoView({ block: "center" });
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1561
|
+
}, [open, currentRoom, flatNav, navIndexByKey]);
|
|
1344
1562
|
// Latest visual order, for reorderManual (defined earlier in the component).
|
|
1345
1563
|
// Every session in the current scope, in manual order — the canonical list
|
|
1346
1564
|
// reordering works against, regardless of what the filter is showing.
|
|
@@ -3482,15 +3482,25 @@ button.primary {
|
|
|
3482
3482
|
overflow: hidden;
|
|
3483
3483
|
}
|
|
3484
3484
|
.switcher-preview-screen {
|
|
3485
|
+
/* BOTTOM-anchored. Anchored at the top, a screen even slightly taller than
|
|
3486
|
+
its tile had its LAST rows clipped — and the last rows are the composer
|
|
3487
|
+
and the newest output, i.e. the entire reason to look at the tile. The
|
|
3488
|
+
autofit claim can't rescue it either: a one-row overflow is inside the
|
|
3489
|
+
~15% tolerance, so no resize is ever requested and the crop is
|
|
3490
|
+
permanent. Overflow now eats the OLDEST rows off the top instead. */
|
|
3485
3491
|
position: absolute;
|
|
3486
|
-
|
|
3492
|
+
bottom: 0;
|
|
3487
3493
|
left: 0;
|
|
3488
|
-
transform-origin:
|
|
3494
|
+
transform-origin: bottom left;
|
|
3489
3495
|
font-family: var(--font-terminal);
|
|
3490
3496
|
}
|
|
3491
3497
|
.switcher-preview-screen .switcher-preview-row {
|
|
3492
|
-
height
|
|
3493
|
-
|
|
3498
|
+
/* --tile-row-h is xterm's OWN row height for this font (derived from the
|
|
3499
|
+
font's natural line box, not fontSize — see tileRowHeight). Matching it
|
|
3500
|
+
is what keeps preview rows on the same baselines the live terminal will
|
|
3501
|
+
use; the 1.3em fallback only applies before the measurement lands. */
|
|
3502
|
+
height: var(--tile-row-h, 1.3em);
|
|
3503
|
+
line-height: var(--tile-row-h, 1.3em);
|
|
3494
3504
|
}
|
|
3495
3505
|
|
|
3496
3506
|
|
|
@@ -3586,6 +3596,31 @@ button.primary {
|
|
|
3586
3596
|
white-space: nowrap;
|
|
3587
3597
|
}
|
|
3588
3598
|
|
|
3599
|
+
/* Live-tile: the session's grid is bigger than this tile, so the view is
|
|
3600
|
+
cropped. Same pill vocabulary as the presence chrome — this is status,
|
|
3601
|
+
not an error, because someone else legitimately owns the size. */
|
|
3602
|
+
.switcher-live-chrome .switcher-tile-offgrid {
|
|
3603
|
+
display: inline-flex;
|
|
3604
|
+
align-items: center;
|
|
3605
|
+
gap: 3px;
|
|
3606
|
+
font-family: var(--font-mono);
|
|
3607
|
+
font-size: 10.5px;
|
|
3608
|
+
color: var(--ink-muted);
|
|
3609
|
+
padding: 0 5px;
|
|
3610
|
+
height: 16px;
|
|
3611
|
+
border: 1px solid color-mix(in srgb, var(--ink-muted) 30%, transparent);
|
|
3612
|
+
border-radius: 4px;
|
|
3613
|
+
background: color-mix(in srgb, var(--surface) 80%, transparent);
|
|
3614
|
+
cursor: pointer;
|
|
3615
|
+
}
|
|
3616
|
+
.switcher-live-chrome .switcher-tile-offgrid:hover {
|
|
3617
|
+
color: var(--ink);
|
|
3618
|
+
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
|
3619
|
+
}
|
|
3620
|
+
.switcher-live-chrome .offgrid-glyph {
|
|
3621
|
+
opacity: 0.75;
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3589
3624
|
/* Live-tile: who else is here, and who is driving. */
|
|
3590
3625
|
.switcher-live-chrome .switcher-live-viewers {
|
|
3591
3626
|
display: inline-flex;
|
|
@@ -24,10 +24,23 @@ type TerminalLike = {
|
|
|
24
24
|
modes?: { mouseTrackingMode?: string };
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
/** Lines this wheel event should move, mirroring xterm's own arithmetic. */
|
|
28
|
+
const wheelLines = (e: WheelEvent, opts: { linesPerNotch: number; lineHeightPx: () => number }) =>
|
|
29
|
+
e.deltaMode === 1
|
|
30
|
+
? e.deltaY * opts.linesPerNotch
|
|
31
|
+
: (e.deltaY / Math.max(1, opts.lineHeightPx())) * opts.linesPerNotch;
|
|
32
|
+
|
|
33
|
+
// WHO OWNS THE WHEEL. Momentum (and scrollback UI generally) is only honest
|
|
34
|
+
// when the TERMINAL owns scrolling: the normal buffer, with the app not
|
|
35
|
+
// tracking the mouse. Otherwise the app owns the wheel and we keep our hands
|
|
36
|
+
// off — xterm forwards the notch and the app scrolls its own view.
|
|
37
|
+
//
|
|
38
|
+
// Measured on the live fleet (2026-07-31): every Claude Code session runs
|
|
39
|
+
// ALT-SCREEN (?1049h) with mouse tracking on, so Claude is always in the
|
|
40
|
+
// app-owns-it branch — its transcript is its own to scroll, and there is no
|
|
41
|
+
// local scrollback behind it to move. Do not "improve" this by scrolling
|
|
42
|
+
// locally when tracking is on: a tracking app asked for wheel events, and
|
|
43
|
+
// stealing them breaks the very apps that requested them.
|
|
31
44
|
const terminalOwnsScrolling = (term: TerminalLike) =>
|
|
32
45
|
term.buffer.active.type === "normal" && (term.modes?.mouseTrackingMode ?? "none") === "none";
|
|
33
46
|
|
|
@@ -61,13 +74,14 @@ export const attachScrollFlywheel = (
|
|
|
61
74
|
): (() => void) => {
|
|
62
75
|
let vel = 0; // lines/second, signed
|
|
63
76
|
let carry = 0; // fractional-line remainder between frames
|
|
77
|
+
let synthetic = false; // true while WE re-dispatch a wheel event
|
|
64
78
|
let burst = 0; // wheel events in the current spin
|
|
65
79
|
let lastWheelAt = 0;
|
|
66
80
|
let lastFrameAt = 0;
|
|
67
81
|
let raf = 0;
|
|
68
82
|
let idleTimer = 0;
|
|
69
83
|
|
|
70
|
-
const
|
|
84
|
+
const stopLocal = () => {
|
|
71
85
|
if (raf) cancelAnimationFrame(raf);
|
|
72
86
|
if (idleTimer) window.clearTimeout(idleTimer);
|
|
73
87
|
raf = 0;
|
|
@@ -77,6 +91,102 @@ export const attachScrollFlywheel = (
|
|
|
77
91
|
burst = 0;
|
|
78
92
|
};
|
|
79
93
|
|
|
94
|
+
// ---- App-owned scrolling (alt screen / mouse tracking) -------------------
|
|
95
|
+
// The app scrolls itself, one step per wheel event. We re-dispatch wheel
|
|
96
|
+
// events to give that stream a natural shape: a notch is worth a few steps,
|
|
97
|
+
// and a spin coasts to a stop instead of halting dead. Re-dispatching (vs
|
|
98
|
+
// encoding mouse reports ourselves) keeps xterm the single owner of the
|
|
99
|
+
// wire format — whatever it sends for a real notch is exactly what it sends
|
|
100
|
+
// for ours.
|
|
101
|
+
const APP_STEPS_PER_NOTCH = 3;
|
|
102
|
+
const APP_MAX_RATE = 90; // steps/sec — fast, still ordered
|
|
103
|
+
let appVel = 0;
|
|
104
|
+
let appCarry = 0;
|
|
105
|
+
let appRaf = 0;
|
|
106
|
+
let appIdle = 0;
|
|
107
|
+
let appBurst = 0;
|
|
108
|
+
let appLastAt = 0;
|
|
109
|
+
let appTarget: EventTarget | null = null;
|
|
110
|
+
let appProto: { deltaY: number; clientX: number; clientY: number } | null = null;
|
|
111
|
+
|
|
112
|
+
const stopApp = () => {
|
|
113
|
+
if (appRaf) cancelAnimationFrame(appRaf);
|
|
114
|
+
if (appIdle) window.clearTimeout(appIdle);
|
|
115
|
+
appRaf = 0;
|
|
116
|
+
appIdle = 0;
|
|
117
|
+
appVel = 0;
|
|
118
|
+
appCarry = 0;
|
|
119
|
+
appBurst = 0;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const stop = () => { stopLocal(); stopApp(); };
|
|
123
|
+
|
|
124
|
+
const dispatchStep = () => {
|
|
125
|
+
if (!appTarget || !appProto) return;
|
|
126
|
+
synthetic = true;
|
|
127
|
+
try {
|
|
128
|
+
appTarget.dispatchEvent(new WheelEvent("wheel", {
|
|
129
|
+
deltaY: appProto.deltaY,
|
|
130
|
+
deltaMode: 0,
|
|
131
|
+
clientX: appProto.clientX,
|
|
132
|
+
clientY: appProto.clientY,
|
|
133
|
+
bubbles: true,
|
|
134
|
+
cancelable: true
|
|
135
|
+
}));
|
|
136
|
+
} catch {
|
|
137
|
+
/* target detached mid-glide */
|
|
138
|
+
} finally {
|
|
139
|
+
synthetic = false;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const appGlide = (t: number) => {
|
|
144
|
+
appRaf = 0;
|
|
145
|
+
const term = getTerm();
|
|
146
|
+
// Mode flipped (app exited, screen switched) or run out of speed.
|
|
147
|
+
if (!term || terminalOwnsScrolling(term) || Math.abs(appVel) < 4) return stopApp();
|
|
148
|
+
const dt = lastFrameAt ? Math.min(0.05, (t - lastFrameAt) / 1000) : 0.016;
|
|
149
|
+
lastFrameAt = t;
|
|
150
|
+
appCarry += Math.abs(appVel) * dt;
|
|
151
|
+
let steps = Math.trunc(appCarry);
|
|
152
|
+
appCarry -= steps;
|
|
153
|
+
while (steps-- > 0) dispatchStep();
|
|
154
|
+
appVel *= Math.exp(-dt / 0.35);
|
|
155
|
+
appRaf = requestAnimationFrame(appGlide);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const appWheel = (e: WheelEvent) => {
|
|
159
|
+
appTarget = e.target;
|
|
160
|
+
appProto = { deltaY: e.deltaY, clientX: e.clientX, clientY: e.clientY };
|
|
161
|
+
if (appRaf) { cancelAnimationFrame(appRaf); appRaf = 0; }
|
|
162
|
+
|
|
163
|
+
const now = e.timeStamp || performance.now();
|
|
164
|
+
const gap = now - appLastAt;
|
|
165
|
+
appLastAt = now;
|
|
166
|
+
if (gap > 250) appBurst = 0;
|
|
167
|
+
appBurst += 1;
|
|
168
|
+
|
|
169
|
+
// One notch already produced one step (the real event xterm just
|
|
170
|
+
// forwarded); add the rest so a notch moves like a notch.
|
|
171
|
+
for (let i = 1; i < APP_STEPS_PER_NOTCH; i++) dispatchStep();
|
|
172
|
+
|
|
173
|
+
const rate = APP_STEPS_PER_NOTCH / (Math.min(Math.max(gap, 30), 200) / 1000);
|
|
174
|
+
const signed = Math.sign(e.deltaY) * rate;
|
|
175
|
+
appVel = Math.sign(signed) === Math.sign(appVel) ? 0.6 * signed + 0.4 * appVel : signed;
|
|
176
|
+
appVel = Math.max(-APP_MAX_RATE, Math.min(APP_MAX_RATE, appVel));
|
|
177
|
+
|
|
178
|
+
if (appIdle) window.clearTimeout(appIdle);
|
|
179
|
+
appIdle = window.setTimeout(() => {
|
|
180
|
+
appIdle = 0;
|
|
181
|
+
if (appBurst >= 3 && Math.abs(appVel) >= 4) {
|
|
182
|
+
lastFrameAt = 0;
|
|
183
|
+
appRaf = requestAnimationFrame(appGlide);
|
|
184
|
+
} else {
|
|
185
|
+
stopApp();
|
|
186
|
+
}
|
|
187
|
+
}, 80);
|
|
188
|
+
};
|
|
189
|
+
|
|
80
190
|
const glide = (t: number) => {
|
|
81
191
|
raf = 0;
|
|
82
192
|
const term = getTerm();
|
|
@@ -96,11 +206,24 @@ export const attachScrollFlywheel = (
|
|
|
96
206
|
};
|
|
97
207
|
|
|
98
208
|
const onWheel = (e: WheelEvent) => {
|
|
209
|
+
if (synthetic) return; // our own re-dispatch — never feed it back in
|
|
99
210
|
const term = getTerm();
|
|
100
211
|
if (!term) return;
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
212
|
+
// App owns the wheel (alt screen, or mouse tracking on — i.e. every
|
|
213
|
+
// Claude session). We must not scroll locally, but a terminal app moves
|
|
214
|
+
// one step per wheel event, so a discrete wheel crawls a row at a time.
|
|
215
|
+
// Give it the SAME feel a native scroll has: amplify each notch into a
|
|
216
|
+
// few events, then coast with a decaying stream after the spin. The app
|
|
217
|
+
// still does all the scrolling; we only shape the event rate.
|
|
218
|
+
if (!terminalOwnsScrolling(term)) {
|
|
219
|
+
stopLocal();
|
|
220
|
+
if (isDiscreteWheel(e)) appWheel(e);
|
|
221
|
+
else stopApp(); // trackpad: the OS already sends a dense glide
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
stopApp();
|
|
225
|
+
// Trackpad: native inertia already glides; never double it.
|
|
226
|
+
if (!isDiscreteWheel(e)) return stop();
|
|
104
227
|
if (raf) { cancelAnimationFrame(raf); raf = 0; } // wheel resumes: rebuild, don't glide yet
|
|
105
228
|
|
|
106
229
|
const now = e.timeStamp || performance.now();
|
|
@@ -109,10 +232,7 @@ export const attachScrollFlywheel = (
|
|
|
109
232
|
if (gap > 250) burst = 0;
|
|
110
233
|
burst += 1;
|
|
111
234
|
|
|
112
|
-
const notchLines =
|
|
113
|
-
e.deltaMode === 1
|
|
114
|
-
? e.deltaY * opts.linesPerNotch
|
|
115
|
-
: (e.deltaY / Math.max(1, opts.lineHeightPx())) * opts.linesPerNotch;
|
|
235
|
+
const notchLines = wheelLines(e, opts);
|
|
116
236
|
const rate = notchLines / (Math.min(Math.max(gap, 30), 200) / 1000);
|
|
117
237
|
// Same direction: blend toward the new rate. Direction flip: hard reset.
|
|
118
238
|
vel = Math.sign(rate) === Math.sign(vel) ? 0.6 * rate + 0.4 * vel : rate;
|
|
@@ -133,12 +253,18 @@ export const attachScrollFlywheel = (
|
|
|
133
253
|
};
|
|
134
254
|
|
|
135
255
|
const kill = () => stop();
|
|
136
|
-
|
|
256
|
+
// CAPTURE phase, but strictly observational (passive: we never
|
|
257
|
+
// preventDefault). Capture is REQUIRED: when the app is tracking the
|
|
258
|
+
// mouse, xterm's own handler calls stopPropagation() on the way out, so a
|
|
259
|
+
// bubble-phase listener on this ancestor never runs at all — which is why
|
|
260
|
+
// the app-scroll shaping below silently did nothing. Observing first lets
|
|
261
|
+
// xterm do exactly what it always did, and lets us add steps around it.
|
|
262
|
+
el.addEventListener("wheel", onWheel, { capture: true, passive: true });
|
|
137
263
|
el.addEventListener("mousedown", kill, true);
|
|
138
264
|
window.addEventListener("keydown", kill, true);
|
|
139
265
|
return () => {
|
|
140
266
|
stop();
|
|
141
|
-
el.removeEventListener("wheel", onWheel);
|
|
267
|
+
el.removeEventListener("wheel", onWheel, { capture: true } as EventListenerOptions);
|
|
142
268
|
el.removeEventListener("mousedown", kill, true);
|
|
143
269
|
window.removeEventListener("keydown", kill, true);
|
|
144
270
|
};
|
|
@@ -152,6 +152,19 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
|
|
|
152
152
|
return { handleKey, dispose, isActive: () => active };
|
|
153
153
|
};
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Is this surface running Claude Code? Used for KEY ENCODING (Shift+Enter's
|
|
157
|
+
* CSI-u) and voice eligibility, so it must stay true for RESTORED sessions:
|
|
158
|
+
* an argv-launched restore (`shell -lc "claude …; exec shell -l"`) reports
|
|
159
|
+
* the wrapper shell as the foreground process forever, so a process-only
|
|
160
|
+
* check goes false on every session that came back through `hop restore`.
|
|
161
|
+
* The terminal's own chrome is the honest second witness.
|
|
162
|
+
*/
|
|
163
|
+
export const isClaudeSurface = (
|
|
164
|
+
foregroundIsClaude: boolean,
|
|
165
|
+
terminal: Parameters<typeof bufferLooksLikeClaude>[0] | null | undefined
|
|
166
|
+
): boolean => foregroundIsClaude || (!!terminal && bufferLooksLikeClaude(terminal));
|
|
167
|
+
|
|
155
168
|
/** Claude chrome scan over a terminal's visible rows — shared eligibility. */
|
|
156
169
|
export const bufferLooksLikeClaude = (t: {
|
|
157
170
|
rows: number;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isClaudeSurface } from "../src/utils/voiceHold";
|
|
3
|
+
|
|
4
|
+
// A fake xterm buffer showing the given visible rows.
|
|
5
|
+
const term = (rows: string[]) => ({
|
|
6
|
+
rows: rows.length,
|
|
7
|
+
buffer: {
|
|
8
|
+
active: {
|
|
9
|
+
baseY: 0,
|
|
10
|
+
getLine: (row: number) =>
|
|
11
|
+
rows[row] === undefined ? undefined : { translateToString: () => rows[row] }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const CLAUDE_SCREEN = [
|
|
17
|
+
"> summarize this file",
|
|
18
|
+
"",
|
|
19
|
+
"⏵⏵ bypass permissions on (shift+tab to cycle)"
|
|
20
|
+
];
|
|
21
|
+
const SHELL_SCREEN = ["jianzhou@MED-GEN-ML-15 hop2 % ls", "hop hay scripts", "jianzhou@MED-GEN-ML-15 hop2 %"];
|
|
22
|
+
|
|
23
|
+
describe("isClaudeSurface", () => {
|
|
24
|
+
it("trusts the foreground process when it names Claude", () => {
|
|
25
|
+
expect(isClaudeSurface(true, null)).toBe(true);
|
|
26
|
+
expect(isClaudeSurface(true, term(SHELL_SCREEN) as never)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// THE regression: `hop restore` launches Claude as the room's argv
|
|
30
|
+
// (`shell -lc "claude …; exec shell -l"`), so the room reports the WRAPPER
|
|
31
|
+
// SHELL as its foreground process for the rest of the session's life. A
|
|
32
|
+
// process-only check therefore reads false on every restored session —
|
|
33
|
+
// which silently disabled Shift+Enter (CSI 13;2u) in wall tiles, turning
|
|
34
|
+
// every newline attempt into a submit.
|
|
35
|
+
it("still recognizes Claude when the process check is blind (restored session)", () => {
|
|
36
|
+
expect(isClaudeSurface(false, term(CLAUDE_SCREEN) as never)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("stays false for a plain shell, where raw CSI-u would render as junk", () => {
|
|
40
|
+
expect(isClaudeSurface(false, term(SHELL_SCREEN) as never)).toBe(false);
|
|
41
|
+
expect(isClaudeSurface(false, null)).toBe(false);
|
|
42
|
+
expect(isClaudeSurface(false, undefined)).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|