Agent Manager commited on
Commit
ae8fb4c
·
1 Parent(s): b7e3caa

Handle mobile keyboard viewport signals

Browse files
server/mobile.test.mjs CHANGED
@@ -146,6 +146,15 @@ try {
146
  Object.defineProperty(window, 'visualViewport', {
147
  configurable: true, value: viewport,
148
  });
 
 
 
 
 
 
 
 
 
149
  window.__setVisualViewport = (height, offsetTop, width = viewport.width) => {
150
  viewport.width = width;
151
  viewport.height = height;
@@ -154,6 +163,18 @@ try {
154
  viewport.dispatchEvent(new Event('resize'));
155
  viewport.dispatchEvent(new Event('scroll'));
156
  };
 
 
 
 
 
 
 
 
 
 
 
 
157
  });
158
  const page = await context.newPage();
159
  await page.goto(WEB, { waitUntil: 'domcontentloaded' });
@@ -261,9 +282,44 @@ try {
261
  Math.abs(returnedScroll.top - returnedScroll.max) < 2,
262
  JSON.stringify(returnedScroll));
263
 
264
- const beforeKeyboardGrid = latestGrid();
265
  await page.locator('.term-host').click();
266
- await page.evaluate(() => window.__setVisualViewport(360, 118));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  const keyboardResized = await waitFor(() => {
268
  const frame = latestGrid();
269
  return frame?.controller === false && frame?.rows < beforeKeyboardGrid.rows;
 
146
  Object.defineProperty(window, 'visualViewport', {
147
  configurable: true, value: viewport,
148
  });
149
+ const virtualKeyboard = new EventTarget();
150
+ virtualKeyboard.boundingRect = new DOMRect(0, 667, 375, 0);
151
+ Object.defineProperty(navigator, 'virtualKeyboard', {
152
+ configurable: true, value: virtualKeyboard,
153
+ });
154
+ window.__setVirtualKeyboard = (top, height, width = viewport.width) => {
155
+ virtualKeyboard.boundingRect = new DOMRect(0, top, width, height);
156
+ virtualKeyboard.dispatchEvent(new Event('geometrychange'));
157
+ };
158
  window.__setVisualViewport = (height, offsetTop, width = viewport.width) => {
159
  viewport.width = width;
160
  viewport.height = height;
 
163
  viewport.dispatchEvent(new Event('resize'));
164
  viewport.dispatchEvent(new Event('scroll'));
165
  };
166
+ // Safari can fire with the final height but a stale zero offset, then update
167
+ // offsetTop without another event. Focus stabilization must catch that.
168
+ window.__setVisualViewportLate = (height, offsetTop, delay = 80) => {
169
+ viewport.height = height;
170
+ viewport.offsetTop = 0;
171
+ viewport.pageTop = 0;
172
+ viewport.dispatchEvent(new Event('resize'));
173
+ setTimeout(() => {
174
+ viewport.offsetTop = offsetTop;
175
+ viewport.pageTop = offsetTop;
176
+ }, delay);
177
+ };
178
  });
179
  const page = await context.newPage();
180
  await page.goto(WEB, { waitUntil: 'domcontentloaded' });
 
282
  Math.abs(returnedScroll.top - returnedScroll.max) < 2,
283
  JSON.stringify(returnedScroll));
284
 
285
+ const fullKeyboardGrid = latestGrid();
286
  await page.locator('.term-host').click();
287
+ const mobileInputAnchor = await page.evaluate(() => {
288
+ const host = document.querySelector('.term-host').getBoundingClientRect();
289
+ const node = document.querySelector('.term-host .xterm-helper-textarea');
290
+ const input = node.getBoundingClientRect();
291
+ return {
292
+ ok: input.width >= 1 && input.height >= 1
293
+ && input.top >= host.bottom - 5 && input.left > host.left && input.right < host.right,
294
+ host: { top: host.top, right: host.right, bottom: host.bottom, left: host.left },
295
+ input: { top: input.top, right: input.right, bottom: input.bottom, left: input.left,
296
+ width: input.width, height: input.height },
297
+ style: node.getAttribute('style'),
298
+ };
299
+ });
300
+ check('the focused xterm input is anchored at the mobile terminal bottom',
301
+ mobileInputAnchor.ok, JSON.stringify(mobileInputAnchor));
302
+
303
+ // Embedded Chromium can leave the child visual viewport unchanged but expose
304
+ // the OSK rectangle. Ensure that independent signal clips the app and PTY.
305
+ await page.evaluate(() => window.__setVirtualKeyboard(430, 237));
306
+ const geometryResized = await waitFor(() => latestGrid()?.rows < fullKeyboardGrid.rows);
307
+ const geometryLayout = await page.locator('.app').evaluate((node) => {
308
+ const box = node.getBoundingClientRect();
309
+ const keybar = document.querySelector('.term-keybar')?.getBoundingClientRect();
310
+ return { top: box.top, bottom: box.bottom, height: box.height, keybarBottom: keybar?.bottom ?? null };
311
+ });
312
+ check('keyboard geometry resizes an embedded mobile terminal without viewport changes',
313
+ geometryResized && Math.abs(geometryLayout.height - 430) < 1
314
+ && geometryLayout.keybarBottom <= 430,
315
+ JSON.stringify({ geometryResized, geometryLayout, grid: latestGrid() }));
316
+ await page.evaluate(() => window.__setVirtualKeyboard(667, 0));
317
+ const geometryClosed = await waitFor(() => latestGrid()?.rows >= fullKeyboardGrid.rows);
318
+ check('clearing embedded keyboard geometry restores the terminal', geometryClosed,
319
+ JSON.stringify(latestGrid()));
320
+
321
+ const beforeKeyboardGrid = latestGrid();
322
+ await page.evaluate(() => window.__setVisualViewportLate(360, 118));
323
  const keyboardResized = await waitFor(() => {
324
  const frame = latestGrid();
325
  return frame?.controller === false && frame?.rows < beforeKeyboardGrid.rows;
web/src/App.tsx CHANGED
@@ -106,20 +106,66 @@ export default function App() {
106
  // The CSS variables pin the app to that viewport's exact rectangle.
107
  useEffect(() => {
108
  const vv = window.visualViewport;
109
- if (!vv) return;
 
 
110
  const apply = () => {
 
 
 
 
 
 
 
 
 
 
 
111
  const root = document.documentElement.style;
112
- root.setProperty('--vvw', `${Math.round(vv.width)}px`);
113
- root.setProperty('--vvh', `${Math.round(vv.height)}px`);
114
- root.setProperty('--vv-top', `${Math.round(vv.offsetTop)}px`);
115
- root.setProperty('--vv-left', `${Math.round(vv.offsetLeft)}px`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  };
117
  apply();
118
- vv.addEventListener('resize', apply);
119
- vv.addEventListener('scroll', apply);
 
 
 
 
 
 
120
  return () => {
121
- vv.removeEventListener('resize', apply);
122
- vv.removeEventListener('scroll', apply);
 
 
 
 
 
 
 
 
123
  document.documentElement.style.removeProperty('--vvw');
124
  document.documentElement.style.removeProperty('--vvh');
125
  document.documentElement.style.removeProperty('--vv-top');
 
106
  // The CSS variables pin the app to that viewport's exact rectangle.
107
  useEffect(() => {
108
  const vv = window.visualViewport;
109
+ type VirtualKeyboardLike = EventTarget & { boundingRect?: DOMRectReadOnly };
110
+ const keyboard = (navigator as Navigator & { virtualKeyboard?: VirtualKeyboardLike }).virtualKeyboard;
111
+ if (!vv && !keyboard) return;
112
  const apply = () => {
113
+ const keyboardRect = keyboard?.boundingRect;
114
+ const left = vv?.offsetLeft ?? 0;
115
+ const top = vv?.offsetTop ?? 0;
116
+ const width = vv?.width ?? document.documentElement.clientWidth;
117
+ let height = vv?.height ?? window.innerHeight;
118
+ // Chromium can expose keyboard geometry even when an embedded document's
119
+ // visual viewport is unchanged. Never grow the visual viewport from it;
120
+ // only clip an actually overlapping keyboard.
121
+ if (keyboardRect && keyboardRect.height > 0 && keyboardRect.top > top) {
122
+ height = Math.min(height, keyboardRect.top - top);
123
+ }
124
  const root = document.documentElement.style;
125
+ root.setProperty('--vvw', `${Math.round(width)}px`);
126
+ root.setProperty('--vvh', `${Math.round(height)}px`);
127
+ root.setProperty('--vv-top', `${Math.round(top)}px`);
128
+ root.setProperty('--vv-left', `${Math.round(left)}px`);
129
+ };
130
+
131
+ // WebKit may dispatch the keyboard viewport event before offsetTop has its
132
+ // final value, then correct the property without another event. Re-read at
133
+ // the end of the event turn and while the focus animation settles.
134
+ let settleTimer: ReturnType<typeof setTimeout> | null = null;
135
+ const focusTimers = new Set<ReturnType<typeof setTimeout>>();
136
+ const onViewportChange = () => {
137
+ apply();
138
+ if (settleTimer) clearTimeout(settleTimer);
139
+ settleTimer = setTimeout(() => { settleTimer = null; apply(); }, 80);
140
+ };
141
+ const stabilizeFocus = () => {
142
+ for (const timer of focusTimers) clearTimeout(timer);
143
+ focusTimers.clear();
144
+ for (const delay of [0, 50, 150, 300, 500, 800]) {
145
+ const timer = setTimeout(() => { focusTimers.delete(timer); apply(); }, delay);
146
+ focusTimers.add(timer);
147
+ }
148
  };
149
  apply();
150
+ vv?.addEventListener('resize', onViewportChange);
151
+ vv?.addEventListener('scroll', onViewportChange);
152
+ vv?.addEventListener('scrollend', onViewportChange);
153
+ keyboard?.addEventListener('geometrychange', onViewportChange);
154
+ window.addEventListener('resize', onViewportChange);
155
+ window.addEventListener('orientationchange', stabilizeFocus);
156
+ document.addEventListener('focusin', stabilizeFocus);
157
+ document.addEventListener('focusout', stabilizeFocus);
158
  return () => {
159
+ if (settleTimer) clearTimeout(settleTimer);
160
+ for (const timer of focusTimers) clearTimeout(timer);
161
+ vv?.removeEventListener('resize', onViewportChange);
162
+ vv?.removeEventListener('scroll', onViewportChange);
163
+ vv?.removeEventListener('scrollend', onViewportChange);
164
+ keyboard?.removeEventListener('geometrychange', onViewportChange);
165
+ window.removeEventListener('resize', onViewportChange);
166
+ window.removeEventListener('orientationchange', stabilizeFocus);
167
+ document.removeEventListener('focusin', stabilizeFocus);
168
+ document.removeEventListener('focusout', stabilizeFocus);
169
  document.documentElement.style.removeProperty('--vvw');
170
  document.documentElement.style.removeProperty('--vvh');
171
  document.documentElement.style.removeProperty('--vv-top');
web/src/components/TerminalPane.tsx CHANGED
@@ -235,6 +235,24 @@ export default function TerminalPane({
235
  term.loadAddon(new (ClipboardAddon as unknown as new (b: Base64, p: typeof clipboardProvider) => ClipboardAddon)(new Base64(), clipboardProvider));
236
  term.open(hostRef.current!);
237
  termRef.current = term;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
 
239
  // Track the user's semantic scroll state independently of the viewport's
240
  // pixel scrollTop. During a row-count change xterm can transiently report
@@ -253,7 +271,6 @@ export default function TerminalPane({
253
  const text = term.hasSelection() ? selectionText(term) : lastSelection;
254
  if (text) copyText(text);
255
  };
256
- const host = hostRef.current!;
257
  const onCopy = (e: ClipboardEvent) => {
258
  if (!term.hasSelection()) return;
259
  const text = selectionText(term);
@@ -452,6 +469,7 @@ export default function TerminalPane({
452
  // ResizeObserver fires every frame while a window is dragged or the sidebar
453
  // animates. Ask once, when it stops.
454
  const resync = () => {
 
455
  if (resyncTimer) clearTimeout(resyncTimer);
456
  resyncTimer = setTimeout(() => { resyncTimer = null; requestSize(); }, 80);
457
  };
 
235
  term.loadAddon(new (ClipboardAddon as unknown as new (b: Base64, p: typeof clipboardProvider) => ClipboardAddon)(new Base64(), clipboardProvider));
236
  term.open(hostRef.current!);
237
  termRef.current = term;
238
+ const host = hostRef.current!;
239
+
240
+ // xterm deliberately parks its real textarea far off-screen. That is fine
241
+ // with a hardware keyboard, but a mobile browser (especially a Space inside
242
+ // a cross-origin iframe) has no visible focus target to pan above the OSK.
243
+ // Keep the still-transparent 1px input at the bottom of the terminal so the
244
+ // browser's native focused-element avoidance can cross the iframe boundary.
245
+ const mobileInput = isMobile
246
+ ? host.querySelector<HTMLTextAreaElement>('.xterm-helper-textarea')
247
+ : null;
248
+ const anchorMobileInput = () => {
249
+ if (!mobileInput) return;
250
+ // xterm rewrites left/top whenever its cursor moves. Custom properties
251
+ // survive those writes and feed the mobile !important rules in CSS.
252
+ mobileInput.style.setProperty('--am-input-left', `${Math.max(1, Math.round(host.clientWidth / 2))}px`);
253
+ mobileInput.style.setProperty('--am-input-top', `${Math.max(1, host.clientHeight - 12)}px`);
254
+ };
255
+ anchorMobileInput();
256
 
257
  // Track the user's semantic scroll state independently of the viewport's
258
  // pixel scrollTop. During a row-count change xterm can transiently report
 
271
  const text = term.hasSelection() ? selectionText(term) : lastSelection;
272
  if (text) copyText(text);
273
  };
 
274
  const onCopy = (e: ClipboardEvent) => {
275
  if (!term.hasSelection()) return;
276
  const text = selectionText(term);
 
469
  // ResizeObserver fires every frame while a window is dragged or the sidebar
470
  // animates. Ask once, when it stops.
471
  const resync = () => {
472
+ anchorMobileInput();
473
  if (resyncTimer) clearTimeout(resyncTimer);
474
  resyncTimer = setTimeout(() => { resyncTimer = null; requestSize(); }, 80);
475
  };
web/src/styles.css CHANGED
@@ -1045,6 +1045,15 @@ a.btn-ghost { text-decoration: none; }
1045
  overflow: hidden;
1046
  }
1047
  .term-host { touch-action: none; overscroll-behavior: contain; }
 
 
 
 
 
 
 
 
 
1048
  .app.m-home .main { display: none; }
1049
  .app.m-home .sidebar { width: 100%; border-right: none; }
1050
  .app.m-stage .sidebar { display: none; }
 
1045
  overflow: hidden;
1046
  }
1047
  .term-host { touch-action: none; overscroll-behavior: contain; }
1048
+ /* Keep xterm's transparent real input at the visible prompt edge. xterm
1049
+ normally parks/moves it for desktop IME behavior; on phones this element
1050
+ is also the browser's only clue about what must remain above the OSK. */
1051
+ .term-host .xterm-helper-textarea {
1052
+ left: var(--am-input-left, 50%) !important;
1053
+ top: var(--am-input-top, calc(100% - 2px)) !important;
1054
+ width: 1px !important;
1055
+ height: 1px !important;
1056
+ }
1057
  .app.m-home .main { display: none; }
1058
  .app.m-home .sidebar { width: 100%; border-right: none; }
1059
  .app.m-stage .sidebar { display: none; }