Thomas Wolf commited on
Commit
99b8ff4
Β·
unverified Β·
2 Parent(s): 7da11a59f10fe7

Merge pull request #51 from huggingface/mobile/reader-touch-and-keyboard

Browse files

Reader on a phone: scroll the trace, and an instrument for the keyboard gap

web/src/App.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
  import Sidebar from './components/Sidebar';
3
  import TerminalPane from './components/TerminalPane';
4
  import FilesPane from './components/FilesPane';
@@ -19,6 +19,12 @@ import { onPaneMode, readPaneMode, writePaneMode } from './lib/paneMode';
19
  import { isPassive, isRemote } from './types';
20
  import { GridGlyph, ListGlyph } from './components/icons';
21
 
 
 
 
 
 
 
22
  // Phone-sized viewport: the app becomes two full-screen views (list ⇄ pane).
23
  function useIsMobile() {
24
  const [m, setM] = useState(() => window.matchMedia('(max-width: 720px)').matches);
@@ -111,10 +117,13 @@ export default function App() {
111
 
112
  // Track the visual viewport so the mobile layout can sit above the on-screen
113
  // keyboard (which shrinks visualViewport but not the layout viewport on iOS).
114
- // The CSS variables pin the app to that viewport's exact rectangle. The Hub
115
- // page embeds the app in a cross-origin iframe; mobile Safari leaves that
116
- // child viewport unchanged when its keyboard opens. In that one no-signal
117
- // case, fall back to a conservative focus-derived visible height.
 
 
 
118
  useEffect(() => {
119
  const vv = window.visualViewport;
120
  type VirtualKeyboardLike = EventTarget & { boundingRect?: DOMRectReadOnly };
@@ -127,11 +136,12 @@ export default function App() {
127
  };
128
  const root = document.documentElement;
129
  const keyboardSignalThreshold = 80;
130
- const embedded = window.self !== window.top;
131
- let focusedInput: Element | null = null;
 
 
 
132
  let focusBaseline: ViewportBaseline | null = null;
133
- let focusFallback = false;
134
- let focusFallbackTimer: ReturnType<typeof setTimeout> | null = null;
135
 
136
  const acceptsKeyboardInput = (target: Element | null): target is HTMLElement => {
137
  if (!(target instanceof HTMLElement)) return false;
@@ -141,9 +151,6 @@ export default function App() {
141
  return !['button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit']
142
  .includes(target.type);
143
  };
144
- const embeddedTouchLayout = () => embedded
145
- && window.matchMedia('(max-width: 720px)').matches
146
- && (navigator.maxTouchPoints > 0 || window.matchMedia('(pointer: coarse)').matches);
147
  const captureViewport = (): ViewportBaseline => ({
148
  width: vv?.width ?? document.documentElement.clientWidth,
149
  height: vv?.height ?? window.innerHeight,
@@ -172,20 +179,25 @@ export default function App() {
172
  if (keyboardRect && keyboardRect.height > 0 && keyboardRect.top > top) {
173
  height = Math.min(height, keyboardRect.top - top);
174
  }
175
- if (hasKeyboardGeometry()) focusFallback = false;
176
- if (focusFallback && focusBaseline && acceptsKeyboardInput(document.activeElement)) {
177
- // The parent page owns the real visual viewport, but cross-origin frame
178
- // isolation prevents us from reading it. A phone keyboard typically
179
- // consumes roughly the lower half; 54% visible keeps the xterm prompt
180
- // above it without disturbing direct-app browsers with real geometry.
181
- const visibleRatio = focusBaseline.width > focusBaseline.height ? 0.48 : 0.54;
182
- height = Math.min(height, Math.round(focusBaseline.height * visibleRatio));
183
- root.dataset.keyboardLayout = 'focus-fallback';
184
- } else if (hasKeyboardGeometry()) {
185
- root.dataset.keyboardLayout = 'browser-geometry';
186
- } else {
187
- delete root.dataset.keyboardLayout;
188
- }
 
 
 
 
 
189
  root.style.setProperty('--vvw', `${Math.round(width)}px`);
190
  root.style.setProperty('--vvh', `${Math.round(height)}px`);
191
  root.style.setProperty('--vv-top', `${Math.round(top)}px`);
@@ -217,54 +229,29 @@ export default function App() {
217
  focusTimers.add(timer);
218
  }
219
  };
220
- const scheduleEmbeddedFallback = () => {
221
- if (focusFallbackTimer) clearTimeout(focusFallbackTimer);
222
- focusFallbackTimer = null;
223
- if (!embeddedTouchLayout() || !acceptsKeyboardInput(document.activeElement)) return;
224
- focusFallbackTimer = setTimeout(() => {
225
- focusFallbackTimer = null;
226
- if (document.activeElement === focusedInput && !hasKeyboardGeometry()) {
227
- focusFallback = true;
228
- apply();
229
- }
230
- }, 500);
231
- };
232
  const onFocusIn = (event: FocusEvent) => {
233
  const target = event.target instanceof Element ? event.target : null;
234
- if (acceptsKeyboardInput(target)) {
235
- focusedInput = target;
236
- focusBaseline = captureViewport();
237
- focusFallback = false;
238
- stabilizeFocus();
239
- scheduleEmbeddedFallback();
240
- return;
241
- }
242
  stabilizeFocus();
243
  };
244
  const onFocusOut = () => {
245
- if (focusFallbackTimer) clearTimeout(focusFallbackTimer);
246
- focusFallbackTimer = null;
247
  const timer = setTimeout(() => {
248
  focusTimers.delete(timer);
249
  if (!acceptsKeyboardInput(document.activeElement)) {
250
- focusedInput = null;
251
  focusBaseline = null;
252
- focusFallback = false;
253
  apply();
254
  }
255
  }, 0);
256
  focusTimers.add(timer);
257
  };
258
  const onOrientationChange = () => {
259
- if (focusFallbackTimer) clearTimeout(focusFallbackTimer);
260
- focusFallbackTimer = null;
261
- focusFallback = false;
262
- if (acceptsKeyboardInput(document.activeElement)) {
263
- focusedInput = document.activeElement;
264
- focusBaseline = captureViewport();
265
- }
266
  stabilizeFocus();
267
- scheduleEmbeddedFallback();
268
  };
269
  apply();
270
  vv?.addEventListener('resize', onViewportChange);
@@ -278,7 +265,6 @@ export default function App() {
278
  return () => {
279
  for (const timer of settleTimers) clearTimeout(timer);
280
  for (const timer of focusTimers) clearTimeout(timer);
281
- if (focusFallbackTimer) clearTimeout(focusFallbackTimer);
282
  vv?.removeEventListener('resize', onViewportChange);
283
  vv?.removeEventListener('scroll', onViewportChange);
284
  vv?.removeEventListener('scrollend', onViewportChange);
@@ -823,6 +809,10 @@ export default function App() {
823
  onToggleDemo={toggleDemo}
824
  />
825
  )}
 
 
 
 
826
  <div className={`app${settingsOpen ? ' app-suspended' : ''}${isMobile ? (mobileStage ? ' m-stage' : ' m-home') : ''}`}>
827
  {showWelcome && <Welcome onClose={dismissWelcome} />}
828
  {toast && <div className="toast mono" role="alert">{toast}</div>}
 
1
+ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
  import Sidebar from './components/Sidebar';
3
  import TerminalPane from './components/TerminalPane';
4
  import FilesPane from './components/FilesPane';
 
19
  import { isPassive, isRemote } from './types';
20
  import { GridGlyph, ListGlyph } from './components/icons';
21
 
22
+ // `?vvdebug=1` β€” a phone has no devtools, and the keyboard layout is a guess
23
+ // when the app is embedded cross-origin. Read once: it never changes mid-run,
24
+ // and lazily imported so a debug surface is not part of the shipped bundle.
25
+ const VV_DEBUG = new URLSearchParams(location.search).has('vvdebug');
26
+ const ViewportDebug = lazy(() => import('./components/ViewportDebug'));
27
+
28
  // Phone-sized viewport: the app becomes two full-screen views (list ⇄ pane).
29
  function useIsMobile() {
30
  const [m, setM] = useState(() => window.matchMedia('(max-width: 720px)').matches);
 
117
 
118
  // Track the visual viewport so the mobile layout can sit above the on-screen
119
  // keyboard (which shrinks visualViewport but not the layout viewport on iOS).
120
+ // The CSS variables pin the app to that viewport's exact rectangle.
121
+ //
122
+ // Where there is no signal β€” the Hub page embeds the app in a cross-origin
123
+ // iframe, and mobile Safari leaves that child viewport unchanged when its
124
+ // keyboard opens β€” the app reports the viewport it can see and stops there.
125
+ // It does not estimate one. Nothing here knows how tall a keyboard is, and
126
+ // the browser that does already scrolls a focused field into view.
127
  useEffect(() => {
128
  const vv = window.visualViewport;
129
  type VirtualKeyboardLike = EventTarget & { boundingRect?: DOMRectReadOnly };
 
136
  };
137
  const root = document.documentElement;
138
  const keyboardSignalThreshold = 80;
139
+ // The viewport as it was before a field took focus β€” the only thing left
140
+ // that needs remembering, because a keyboard is detected as the SHRINK from
141
+ // it (hasKeyboardGeometry), not as an absolute height. Since the estimate
142
+ // was deleted this feeds no layout at all: its one consumer is the
143
+ // keyboardLayout label, which only ?vvdebug=1 reads.
144
  let focusBaseline: ViewportBaseline | null = null;
 
 
145
 
146
  const acceptsKeyboardInput = (target: Element | null): target is HTMLElement => {
147
  if (!(target instanceof HTMLElement)) return false;
 
151
  return !['button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit']
152
  .includes(target.type);
153
  };
 
 
 
154
  const captureViewport = (): ViewportBaseline => ({
155
  width: vv?.width ?? document.documentElement.clientWidth,
156
  height: vv?.height ?? window.innerHeight,
 
179
  if (keyboardRect && keyboardRect.height > 0 && keyboardRect.top > top) {
180
  height = Math.min(height, keyboardRect.top - top);
181
  }
182
+ // When the browser reports no keyboard geometry β€” a cross-origin frame on
183
+ // mobile Safari, which the Hub page is β€” the app does NOT invent a height.
184
+ // It used to assume a keyboard ate 46% and shrink to fit, and a guess that
185
+ // is wrong in the safe direction is still wrong: the abandoned strip does
186
+ // not stay hidden behind the keyboard, because the browser scroll-reveals
187
+ // a focused field and drags it back into view. That strip is the blank
188
+ // band under the reader's composer.
189
+ //
190
+ // What replaces it is the PARENT page's scroll, not ours: measured at
191
+ // 390x844, every box in this document β€” html, body, #root, .app, .main,
192
+ // .term-host, .pane-reader, .cxv β€” has a scroll range of exactly 0, so a
193
+ // scroll-into-view in here moves nothing. The reveal is entirely the
194
+ // embedder's, and this document's job is to not fight it by resizing
195
+ // itself against a keyboard it cannot see.
196
+ //
197
+ // Nothing below sizes anything: with the estimate gone, keyboardLayout is
198
+ // a label for ?vvdebug=1 to read. No CSS matches it.
199
+ if (hasKeyboardGeometry()) root.dataset.keyboardLayout = 'browser-geometry';
200
+ else delete root.dataset.keyboardLayout;
201
  root.style.setProperty('--vvw', `${Math.round(width)}px`);
202
  root.style.setProperty('--vvh', `${Math.round(height)}px`);
203
  root.style.setProperty('--vv-top', `${Math.round(top)}px`);
 
229
  focusTimers.add(timer);
230
  }
231
  };
 
 
 
 
 
 
 
 
 
 
 
 
232
  const onFocusIn = (event: FocusEvent) => {
233
  const target = event.target instanceof Element ? event.target : null;
234
+ // Only when there is no baseline yet: tapping from one field straight to
235
+ // another keeps the keyboard up, and re-reading here would take the
236
+ // shrunk viewport as the "before" β€” after which the shrink measures zero
237
+ // and a keyboard that is plainly up reads as absent. focusout clears it
238
+ // when focus really leaves, so this stays fresh without being re-taken.
239
+ if (acceptsKeyboardInput(target) && !focusBaseline) focusBaseline = captureViewport();
 
 
240
  stabilizeFocus();
241
  };
242
  const onFocusOut = () => {
 
 
243
  const timer = setTimeout(() => {
244
  focusTimers.delete(timer);
245
  if (!acceptsKeyboardInput(document.activeElement)) {
 
246
  focusBaseline = null;
 
247
  apply();
248
  }
249
  }, 0);
250
  focusTimers.add(timer);
251
  };
252
  const onOrientationChange = () => {
253
+ if (acceptsKeyboardInput(document.activeElement)) focusBaseline = captureViewport();
 
 
 
 
 
 
254
  stabilizeFocus();
 
255
  };
256
  apply();
257
  vv?.addEventListener('resize', onViewportChange);
 
265
  return () => {
266
  for (const timer of settleTimers) clearTimeout(timer);
267
  for (const timer of focusTimers) clearTimeout(timer);
 
268
  vv?.removeEventListener('resize', onViewportChange);
269
  vv?.removeEventListener('scroll', onViewportChange);
270
  vv?.removeEventListener('scrollend', onViewportChange);
 
809
  onToggleDemo={toggleDemo}
810
  />
811
  )}
812
+ {/* Outside .app: it reports where .app was put. That does not make it
813
+ immune β€” it is fixed too, so a displaced fixed subtree would carry it
814
+ along β€” but the history it keeps still shows the displacement happening. */}
815
+ {VV_DEBUG && <Suspense fallback={null}><ViewportDebug /></Suspense>}
816
  <div className={`app${settingsOpen ? ' app-suspended' : ''}${isMobile ? (mobileStage ? ' m-stage' : ' m-home') : ''}`}>
817
  {showWelcome && <Welcome onClose={dismissWelcome} />}
818
  {toast && <div className="toast mono" role="alert">{toast}</div>}
web/src/components/TerminalPane.tsx CHANGED
@@ -208,6 +208,9 @@ export default function TerminalPane({
208
  const resyncRef = useRef<() => void>(() => {});
209
  const reconcileScrollRef = useRef<() => void>(() => {});
210
  const claimRef = useRef<() => void>(() => {});
 
 
 
211
  const reconnectRef = useRef<() => void>(() => {});
212
  const controllerRef = useRef(false);
213
  const previousZoomRef = useRef(zoom);
@@ -624,16 +627,19 @@ export default function TerminalPane({
624
  // gesture without moving xterm's viewport, leaving no reliable way back
625
  // through history on touch-only devices. xterm 5.5 registers no touch
626
  // listeners of its own (verified against the bundled lib) and .term-host
627
- // sets touch-action:none on mobile, so neither xterm nor the browser will
628
- // pan: this is the only touch scrolling a phone has, in every pane.
 
 
 
629
  //
630
  // Convert the drag to whole rows and carry the remainder in `residual`.
631
  // Quantising each event to a fixed notch instead silently drops whatever
632
  // does not fill one β€” a 96px drag moved the view 68px β€” and that shortfall
633
  // is what reads as lag, because the text trails the finger by design.
634
  // scrollLines moves ydisp, the authority the viewport follows.
635
- // The frame, not the inner measurement box: .term-host is what carries
636
- // touch-action:none and what the user actually drags, and it stays the
637
  // gesture target however the box inside it is nested.
638
  const frame = frameRef.current ?? host;
639
  const viewport = host.querySelector<HTMLElement>('.xterm-viewport');
@@ -656,7 +662,14 @@ export default function TerminalPane({
656
  return rows;
657
  };
658
  const stopGlide = () => { if (glideFrame) { cancelAnimationFrame(glideFrame); glideFrame = 0; } };
 
 
 
 
 
 
659
  const onTouchStart = (e: TouchEvent) => {
 
660
  stopGlide(); // a new touch takes over from any coasting
661
  velocity = 0;
662
  residual = 0;
@@ -664,6 +677,7 @@ export default function TerminalPane({
664
  lastMoveAt = e.timeStamp;
665
  };
666
  const onTouchMove = (e: TouchEvent) => {
 
667
  if (touchY == null || !e.touches.length) return;
668
  const y = e.touches[0].clientY;
669
  const deltaY = touchY - y;
@@ -691,6 +705,10 @@ export default function TerminalPane({
691
  touchY = null;
692
  const v0 = velocity;
693
  velocity = 0;
 
 
 
 
694
  // Only a flick coasts. A slow, deliberate drag through history must land
695
  // exactly where the finger left it β€” drifting past the line someone was
696
  // reading is worse than having no momentum at all. 0.4px/ms is about
@@ -803,7 +821,10 @@ export default function TerminalPane({
803
  // with focus swallows every keystroke into the agent's TTY, invisibly. Hand
804
  // focus back when the terminal is on top again.
805
  useEffect(() => {
806
- if (reading) termRef.current?.blur();
 
 
 
807
  else if (focused) termRef.current?.focus();
808
  }, [reading, focused]);
809
 
@@ -849,7 +870,11 @@ export default function TerminalPane({
849
  <button className="mini-btn ph-close" title="Close" onClick={(e) => { e.stopPropagation(); onClose(); }}><CloseGlyph /></button>
850
  </div>
851
  </div>
852
- <div className="term-host" ref={frameRef}>
 
 
 
 
853
  <div className="term-fill" ref={hostRef} />
854
  {/* Reader mode draws OVER the terminal rather than replacing it: xterm needs
855
  layout to fit, and detaching tmux costs a repaint and can trip the
 
208
  const resyncRef = useRef<() => void>(() => {});
209
  const reconcileScrollRef = useRef<() => void>(() => {});
210
  const claimRef = useRef<() => void>(() => {});
211
+ // Reachable from the mode switch: a flick can still be coasting through the
212
+ // terminal's scrollback when the reader covers it.
213
+ const stopGlideRef = useRef<() => void>(() => {});
214
  const reconnectRef = useRef<() => void>(() => {});
215
  const controllerRef = useRef(false);
216
  const previousZoomRef = useRef(zoom);
 
627
  // gesture without moving xterm's viewport, leaving no reliable way back
628
  // through history on touch-only devices. xterm 5.5 registers no touch
629
  // listeners of its own (verified against the bundled lib) and .term-host
630
+ // sets touch-action:none on mobile while the terminal is what you see, so
631
+ // neither xterm nor the browser will pan: this is the only touch scrolling
632
+ // a phone has, in every pane. Reader mode is the exception at both ends β€”
633
+ // it hands touch-action back (.term-host.reading) and these handlers stand
634
+ // down β€” because there the scroller you mean to drag is its own.
635
  //
636
  // Convert the drag to whole rows and carry the remainder in `residual`.
637
  // Quantising each event to a fixed notch instead silently drops whatever
638
  // does not fill one β€” a 96px drag moved the view 68px β€” and that shortfall
639
  // is what reads as lag, because the text trails the finger by design.
640
  // scrollLines moves ydisp, the authority the viewport follows.
641
+ // The frame, not the inner measurement box: .term-host is what carries the
642
+ // touch-action rule and what the user actually drags, and it stays the
643
  // gesture target however the box inside it is nested.
644
  const frame = frameRef.current ?? host;
645
  const viewport = host.querySelector<HTMLElement>('.xterm-viewport');
 
662
  return rows;
663
  };
664
  const stopGlide = () => { if (glideFrame) { cancelAnimationFrame(glideFrame); glideFrame = 0; } };
665
+ stopGlideRef.current = stopGlide;
666
+ // Reader mode covers this frame with its own scroller. These listeners sit
667
+ // on .term-host in CAPTURE and preventDefault, so without this guard every
668
+ // drag over the conversation was eaten here and spent on the hidden
669
+ // terminal's scrollback: the trace could not be scrolled back at all on a
670
+ // phone, which is the only place this gesture exists.
671
  const onTouchStart = (e: TouchEvent) => {
672
+ if (modeRef.current === 'reader') return;
673
  stopGlide(); // a new touch takes over from any coasting
674
  velocity = 0;
675
  residual = 0;
 
677
  lastMoveAt = e.timeStamp;
678
  };
679
  const onTouchMove = (e: TouchEvent) => {
680
+ if (modeRef.current === 'reader') return;
681
  if (touchY == null || !e.touches.length) return;
682
  const y = e.touches[0].clientY;
683
  const deltaY = touchY - y;
 
705
  touchY = null;
706
  const v0 = velocity;
707
  velocity = 0;
708
+ // A drag that began on the terminal and ended after the switch flipped
709
+ // must not launch anything: the mode is broadcast app-wide, so the flip
710
+ // can come from another pane rather than from this hand.
711
+ if (modeRef.current === 'reader') return;
712
  // Only a flick coasts. A slow, deliberate drag through history must land
713
  // exactly where the finger left it β€” drifting past the line someone was
714
  // reading is worse than having no momentum at all. 0.4px/ms is about
 
821
  // with focus swallows every keystroke into the agent's TTY, invisibly. Hand
822
  // focus back when the terminal is on top again.
823
  useEffect(() => {
824
+ // The glide too: a flick left coasting under the reader keeps moving a
825
+ // viewport nobody can see, and no touch can catch it β€” the handler that
826
+ // would stop it now stands down in this mode.
827
+ if (reading) { termRef.current?.blur(); stopGlideRef.current(); }
828
  else if (focused) termRef.current?.focus();
829
  }, [reading, focused]);
830
 
 
870
  <button className="mini-btn ph-close" title="Close" onClick={(e) => { e.stopPropagation(); onClose(); }}><CloseGlyph /></button>
871
  </div>
872
  </div>
873
+ {/* `reading` releases the frame's touch-action: the phone rule pins it to
874
+ `none` so the drag handler above owns terminal panning, and that also
875
+ forbids the browser from panning anything nested inside β€” including
876
+ the reader's own scroller. */}
877
+ <div className={`term-host${reading ? ' reading' : ''}`} ref={frameRef}>
878
  <div className="term-fill" ref={hostRef} />
879
  {/* Reader mode draws OVER the terminal rather than replacing it: xterm needs
880
  layout to fit, and detaching tmux costs a repaint and can trip the
web/src/components/ViewportDebug.tsx ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ /**
4
+ * A readout of the numbers the mobile keyboard layout is computed from
5
+ * (App.tsx: the visualViewport effect). iOS Safari has no devtools and the Hub
6
+ * page embeds the app cross-origin, so when the layout lands in the wrong place
7
+ * on a phone there is otherwise nothing to read. Opt in with `?vvdebug=1`.
8
+ *
9
+ * It keeps a short history, not just a live line: the interesting moment is the
10
+ * keyboard animation, and by the time a phone can be screenshotted that is over.
11
+ * A row is recorded only when something changed, so the tail of the list IS the
12
+ * transition β€” which value moved, and which one failed to follow.
13
+ *
14
+ * Writes through textContent on a frame loop rather than setState: this exists
15
+ * to measure a layout animation, and re-rendering the app every frame would
16
+ * perturb the thing being measured.
17
+ */
18
+ const ROWS = 10;
19
+
20
+ export default function ViewportDebug() {
21
+ const out = useRef<HTMLPreElement | null>(null);
22
+
23
+ useEffect(() => {
24
+ let frame = 0;
25
+ let last = '';
26
+ const history: string[] = [];
27
+ // vv.height while nothing is focused. Nothing multiplies it any more β€” the
28
+ // estimate is gone β€” but it is still the number to compare against once a
29
+ // keyboard is up: unchanged here means this document got no signal at all,
30
+ // and inside an iframe it is the FRAME's height, not the screen's.
31
+ let baseline = window.visualViewport?.height ?? window.innerHeight;
32
+
33
+ const num = (n: number) => String(Math.round(n)).padStart(4);
34
+ const active = () => {
35
+ const el = document.activeElement;
36
+ if (!(el instanceof HTMLElement)) return '-';
37
+ const cls = (el.className || '').split(/\s+/)[0];
38
+ return `${el.tagName.toLowerCase()}${cls ? `.${cls}` : ''}`.slice(0, 18);
39
+ };
40
+ const edges = (sel: string) => {
41
+ const el = document.querySelector(sel);
42
+ if (!el) return ' - ';
43
+ const r = el.getBoundingClientRect();
44
+ return `${num(r.top)}${num(r.bottom)}`;
45
+ };
46
+
47
+ const read = () => {
48
+ const vv = window.visualViewport;
49
+ const root = document.documentElement;
50
+ const focused = document.activeElement instanceof HTMLElement
51
+ && /^(input|textarea)$/.test(document.activeElement.tagName.toLowerCase());
52
+ if (!focused) baseline = vv?.height ?? window.innerHeight;
53
+
54
+ // One fixed-width row per sample so a column of them reads as a timeline.
55
+ const row = [
56
+ `vv${num(vv?.height ?? -1)}@${num(vv?.offsetTop ?? -1)}`,
57
+ `css${(root.style.getPropertyValue('--vvh').trim() || '-').padStart(5)}@${(root.style.getPropertyValue('--vv-top').trim() || '-').padStart(4)}`,
58
+ `scr${num(document.scrollingElement?.scrollTop ?? 0)}`,
59
+ `app${edges('.app')}`,
60
+ `box${edges('.cxv-live textarea')}`,
61
+ (root.dataset.keyboardLayout || 'none').slice(0, 8),
62
+ ].join(' ');
63
+ if (row !== last) {
64
+ last = row;
65
+ history.push(row);
66
+ if (history.length > ROWS) history.shift();
67
+ }
68
+
69
+ if (out.current) {
70
+ out.current.textContent = [
71
+ `base ${Math.round(baseline)} inner ${window.innerHeight} vvw ${Math.round(vv?.width ?? -1)}`,
72
+ `screen ${window.screen.width}x${window.screen.height} embed ${window.self !== window.top ? 'y' : 'n'}`,
73
+ `focus ${active()}`,
74
+ '',
75
+ ...history,
76
+ ].join('\n');
77
+ }
78
+ frame = requestAnimationFrame(read);
79
+ };
80
+ frame = requestAnimationFrame(read);
81
+ return () => cancelAnimationFrame(frame);
82
+ }, []);
83
+
84
+ return <pre className="vvdebug" ref={out} />;
85
+ }
web/src/conversation.css CHANGED
@@ -163,7 +163,7 @@ mark.cx-hit.on { background: var(--accent); color: var(--panel); }
163
  you scan for β€” still spanned the full width, so the two disagreed about where
164
  the conversation began. The pane is the measure: make it narrow and the
165
  conversation is narrow. */
166
- .cxv-body { flex: 1; min-height: 0; overflow-y: auto; background: var(--panel); padding: 4px 14px 30px; }
167
  .cxv-col { display: flex; flex-direction: column; min-width: 0; }
168
  .cxv-msg { font-size: 11px; color: var(--muted); padding: 6px 0; }
169
  .cxv-foot {
@@ -174,12 +174,14 @@ mark.cx-hit.on { background: var(--accent); color: var(--panel); }
174
  .cxv-foot .cxv-path { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
175
  .cxv-foot .spacer { flex: 1; min-width: 4px; }
176
 
177
- /* the prompt band sticks: deep inside a long turn, the thing you want overhead
178
- is what you asked for β€” not a row of numbers */
179
- .cxv-col .cx-prompt {
180
- position: sticky; top: 0; z-index: 2;
181
- background: color-mix(in srgb, var(--accent) 7%, var(--panel));
182
- }
 
 
183
 
184
  /* ---------- phone ---------- */
185
  @media (max-width: 720px) {
 
163
  you scan for β€” still spanned the full width, so the two disagreed about where
164
  the conversation began. The pane is the measure: make it narrow and the
165
  conversation is narrow. */
166
+ .cxv-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; background: var(--panel); padding: 4px 14px 30px; }
167
  .cxv-col { display: flex; flex-direction: column; min-width: 0; }
168
  .cxv-msg { font-size: 11px; color: var(--muted); padding: 6px 0; }
169
  .cxv-foot {
 
174
  .cxv-foot .cxv-path { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
175
  .cxv-foot .spacer { flex: 1; min-width: 4px; }
176
 
177
+ /* The prompt band used to stick to the top of this scroller, on the theory that
178
+ deep inside a long turn you want what you asked for overhead. In a pane-sized
179
+ reader it reads as the message refusing to scroll away β€” and on a phone,
180
+ where the band can be a third of what you can see, that is most of the screen
181
+ held back. It scrolls with everything else now; the tint is what marks it.
182
+ The opaque background that came with it went too: it existed so scrolling
183
+ content would not show through a floating band, and over .cxv-body's --panel
184
+ the base transparent tint composites to the same colour. */
185
 
186
  /* ---------- phone ---------- */
187
  @media (max-width: 720px) {
web/src/styles.css CHANGED
@@ -523,6 +523,14 @@ body {
523
  /* transient error toast */
524
  .toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); z-index: 300; max-width: 90vw; padding: 9px 14px; background: color-mix(in srgb, var(--danger) 92%, #000); color: #fff; border-radius: var(--r-md); font-size: 12.5px; box-shadow: 0 8px 28px rgb(0 0 0 / 0.3); animation: rise-in 0.16s ease-out; }
525
 
 
 
 
 
 
 
 
 
526
  /* first-run welcome card */
527
  .welcome-backdrop { position: fixed; inset: 0; z-index: 200; display: flex; align-items: center; justify-content: center; padding: 20px; background: color-mix(in srgb, #000 55%, transparent); backdrop-filter: blur(3px); animation: rise-in 0.16s ease-out; }
528
  .welcome-card { width: 100%; max-width: 560px; max-height: 88vh; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-xl); padding: 22px; box-shadow: 0 24px 60px rgb(0 0 0 / 0.35); }
@@ -1094,6 +1102,13 @@ a.btn-ghost { text-decoration: none; }
1094
  overflow: hidden;
1095
  }
1096
  .term-host { touch-action: none; overscroll-behavior: contain; }
 
 
 
 
 
 
 
1097
  /* Keep xterm's transparent real input at the visible prompt edge. xterm
1098
  normally parks/moves it for desktop IME behavior; on phones this element
1099
  is also the browser's only clue about what must remain above the OSK. */
 
523
  /* transient error toast */
524
  .toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); z-index: 300; max-width: 90vw; padding: 9px 14px; background: color-mix(in srgb, var(--danger) 92%, #000); color: #fff; border-radius: var(--r-md); font-size: 12.5px; box-shadow: 0 8px 28px rgb(0 0 0 / 0.3); animation: rise-in 0.16s ease-out; }
525
 
526
+ /* ?vvdebug=1 β€” the mobile viewport readout. Above everything, transparent to
527
+ touch so it never becomes the reason a gesture misbehaves. */
528
+ .vvdebug {
529
+ position: fixed; top: 0; left: 0; z-index: 999; margin: 0; padding: 4px 6px;
530
+ pointer-events: none; font-family: var(--font-mono); font-size: 9px; line-height: 1.35;
531
+ white-space: pre; color: #9ff; background: rgb(0 0 0 / 0.72); border-bottom-right-radius: 4px;
532
+ }
533
+
534
  /* first-run welcome card */
535
  .welcome-backdrop { position: fixed; inset: 0; z-index: 200; display: flex; align-items: center; justify-content: center; padding: 20px; background: color-mix(in srgb, #000 55%, transparent); backdrop-filter: blur(3px); animation: rise-in 0.16s ease-out; }
536
  .welcome-card { width: 100%; max-width: 560px; max-height: 88vh; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-xl); padding: 22px; box-shadow: 0 24px 60px rgb(0 0 0 / 0.35); }
 
1102
  overflow: hidden;
1103
  }
1104
  .term-host { touch-action: none; overscroll-behavior: contain; }
1105
+ /* touch-action is intersected down the ancestor chain, so `none` here would
1106
+ also forbid panning anything nested inside β€” the reader's scroller, and the
1107
+ code blocks and tables inside it, which are `overflow-x: auto`. Hence
1108
+ `auto` and not `pan-y`: a trace holds wide output, and pinning the axis
1109
+ here would leave that unreachable sideways, the same bug one axis over.
1110
+ The terminal's own drag handler is inert in this mode. */
1111
+ .term-host.reading { touch-action: auto; }
1112
  /* Keep xterm's transparent real input at the visible prompt edge. xterm
1113
  normally parks/moves it for desktop IME behavior; on phones this element
1114
  is also the browser's only clue about what must remain above the OSK. */