Spaces:
Running
Running
Merge pull request #51 from huggingface/mobile/reader-touch-and-keyboard
Browse filesReader on a phone: scroll the trace, and an instrument for the keyboard gap
- web/src/App.tsx +49 -59
- web/src/components/TerminalPane.tsx +31 -6
- web/src/components/ViewportDebug.tsx +85 -0
- web/src/conversation.css +9 -7
- web/src/styles.css +15 -0
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.
|
| 115 |
-
//
|
| 116 |
-
//
|
| 117 |
-
//
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 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 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 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 (
|
| 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
|
| 628 |
-
// pan: this is the only touch scrolling
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
/*
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 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. */
|