Leandro von Werra Agent Manager commited on
Commit
b2599d7
·
unverified ·
1 Parent(s): 0464aa5

Fix retained terminal scroll after activation (#28)

Browse files

Co-authored-by: Agent Manager <agents@agent-manager.local>

server/mobile.test.mjs CHANGED
@@ -123,6 +123,9 @@ try {
123
  super(url, protocols);
124
  if (String(url).includes('/ws?session=')) {
125
  const record = { url: String(url), sent: [], events: [] };
 
 
 
126
  window.__terminalSockets.push(record);
127
  this.addEventListener('open', () => record.events.push({ type: 'open' }));
128
  this.addEventListener('close', (event) => record.events.push({
@@ -236,6 +239,22 @@ try {
236
  await page.locator('.sidebar .row').filter({ hasText: 'mobile-terminal-second' }).first().click();
237
  await page.locator('.tile-terminal:not(.tile-cached) .xterm-screen').waitFor({ state: 'visible' });
238
  const secondOpened = await waitFor(() => page.evaluate(() => window.__terminalSockets.length === 2));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  await page.getByTitle('Back to list').click();
240
  await page.locator('.sidebar .row').filter({ hasText: 'mobile-terminal-e2e' }).first().click();
241
  await page.locator('.tile-terminal:not(.tile-cached) .xterm-screen').waitFor({ state: 'visible' });
@@ -254,6 +273,26 @@ try {
254
  secondOpened && retained.matching === 1 && !retained.closed
255
  && retained.terminals === 2 && retained.hidden === 1,
256
  JSON.stringify({ secondOpened, retained }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  const hiddenMessagesBeforeZoom = await page.evaluate((sessionId) => {
258
  const socket = window.__terminalSockets.find((item) => item.url.includes(`session=${sessionId}`));
259
  return socket?.sent?.length ?? -1;
@@ -529,13 +568,13 @@ try {
529
  const previewSaved = await waitFor(() => page.evaluate((sessionId) => {
530
  try {
531
  const saved = JSON.parse(localStorage.getItem(`am-terminal-preview:${sessionId}`) || 'null');
532
- return saved?.rows?.some((line) => String(line).includes('MOBILE-HISTORY-0220'));
533
  } catch { return false; }
534
  }, id), 5_000);
535
  await page.evaluate(() => localStorage.setItem('__am_test_offline_sockets', '1'));
536
  await page.reload({ waitUntil: 'domcontentloaded' });
537
  await page.locator('.sidebar .row').filter({ hasText: 'mobile-terminal-e2e' }).first().click();
538
- const previewVisible = await page.locator('.term-preview').filter({ hasText: 'MOBILE-HISTORY-0220' })
539
  .isVisible().catch(() => false);
540
  check('the last terminal view survives reload while the backend is unavailable',
541
  previewSaved && previewVisible, JSON.stringify({ previewSaved, previewVisible }));
 
123
  super(url, protocols);
124
  if (String(url).includes('/ws?session=')) {
125
  const record = { url: String(url), sent: [], events: [] };
126
+ Object.defineProperty(record, 'sendRaw', {
127
+ value: (data) => this.send(data),
128
+ });
129
  window.__terminalSockets.push(record);
130
  this.addEventListener('open', () => record.events.push({ type: 'open' }));
131
  this.addEventListener('close', (event) => record.events.push({
 
239
  await page.locator('.sidebar .row').filter({ hasText: 'mobile-terminal-second' }).first().click();
240
  await page.locator('.tile-terminal:not(.tile-cached) .xterm-screen').waitFor({ state: 'visible' });
241
  const secondOpened = await waitFor(() => page.evaluate(() => window.__terminalSockets.length === 2));
242
+
243
+ // Keep writing to the first session while its retained xterm is under
244
+ // display:none. xterm keeps the logical viewport at the live bottom, but a
245
+ // hidden DOM viewport cannot accept that pixel scrollTop. Re-activation must
246
+ // reconcile the two before the first wheel event uses the stale DOM value.
247
+ await page.evaluate(({ sessionId, input }) => {
248
+ const socket = window.__terminalSockets.find((item) => item.url.includes(`session=${sessionId}`));
249
+ socket?.sendRaw(JSON.stringify({ t: 'i', d: input }));
250
+ }, {
251
+ sessionId: id,
252
+ input: "printf '\\033[?1000l\\033[?1006l'; for i in $(seq 221 280); do printf 'MOBILE-HISTORY-%04d\\n' \"$i\"; done\r",
253
+ });
254
+ const hiddenOutputReady = await waitFor(async () => {
255
+ const body = await (await fetch(`${API}/api/agents/${id}/tail?lines=400`)).json();
256
+ return body.text?.includes('MOBILE-HISTORY-0280');
257
+ });
258
  await page.getByTitle('Back to list').click();
259
  await page.locator('.sidebar .row').filter({ hasText: 'mobile-terminal-e2e' }).first().click();
260
  await page.locator('.tile-terminal:not(.tile-cached) .xterm-screen').waitFor({ state: 'visible' });
 
273
  secondOpened && retained.matching === 1 && !retained.closed
274
  && retained.terminals === 2 && retained.hidden === 1,
275
  JSON.stringify({ secondOpened, retained }));
276
+ const reactivatedScroll = await page.locator('.tile-terminal:not(.tile-cached) .xterm-viewport').evaluate((node) => ({
277
+ top: node.scrollTop,
278
+ max: node.scrollHeight - node.clientHeight,
279
+ }));
280
+ check('a retained terminal restores its DOM viewport before wheel input',
281
+ hiddenOutputReady && reactivatedScroll.max > 0
282
+ && Math.abs(reactivatedScroll.top - reactivatedScroll.max) <= 1,
283
+ JSON.stringify({ hiddenOutputReady, reactivatedScroll }));
284
+ await page.locator('.tile-terminal:not(.tile-cached) .xterm').dispatchEvent('wheel', {
285
+ deltaY: -96, deltaMode: 0,
286
+ });
287
+ await sleep(100);
288
+ const wheelTop = await page.locator('.tile-terminal:not(.tile-cached) .xterm-viewport').evaluate((node) => node.scrollTop);
289
+ check('the first upward wheel after re-activation scrolls into history',
290
+ wheelTop < reactivatedScroll.top - 1,
291
+ JSON.stringify({ before: reactivatedScroll.top, after: wheelTop }));
292
+ await page.locator('.tile-terminal:not(.tile-cached) .xterm-viewport').evaluate((node) => {
293
+ node.scrollTop = node.scrollHeight;
294
+ });
295
+ await sleep(100);
296
  const hiddenMessagesBeforeZoom = await page.evaluate((sessionId) => {
297
  const socket = window.__terminalSockets.find((item) => item.url.includes(`session=${sessionId}`));
298
  return socket?.sent?.length ?? -1;
 
568
  const previewSaved = await waitFor(() => page.evaluate((sessionId) => {
569
  try {
570
  const saved = JSON.parse(localStorage.getItem(`am-terminal-preview:${sessionId}`) || 'null');
571
+ return saved?.rows?.some((line) => String(line).includes('MOBILE-HISTORY-0280'));
572
  } catch { return false; }
573
  }, id), 5_000);
574
  await page.evaluate(() => localStorage.setItem('__am_test_offline_sockets', '1'));
575
  await page.reload({ waitUntil: 'domcontentloaded' });
576
  await page.locator('.sidebar .row').filter({ hasText: 'mobile-terminal-e2e' }).first().click();
577
+ const previewVisible = await page.locator('.term-preview').filter({ hasText: 'MOBILE-HISTORY-0280' })
578
  .isVisible().catch(() => false);
579
  check('the last terminal view survives reload while the backend is unavailable',
580
  previewSaved && previewVisible, JSON.stringify({ previewSaved, previewVisible }));
web/src/App.tsx CHANGED
@@ -718,6 +718,7 @@ export default function App() {
718
  theme={theme}
719
  zoom={zoom}
720
  focused={shown && sessions.length > 1 && s.id === focusedId}
 
721
  active={shown && deckVisible && s.id === focusedId}
722
  dragId={shown && canDrag ? `p:${s.id}` : undefined}
723
  isMobile={isMobile}
 
718
  theme={theme}
719
  zoom={zoom}
720
  focused={shown && sessions.length > 1 && s.id === focusedId}
721
+ visible={shown && deckVisible}
722
  active={shown && deckVisible && s.id === focusedId}
723
  dragId={shown && canDrag ? `p:${s.id}` : undefined}
724
  isMobile={isMobile}
web/src/components/TerminalPane.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState } from 'react';
2
  import { Terminal, type ITheme } from '@xterm/xterm';
3
  import { FitAddon } from '@xterm/addon-fit';
4
  import { ClipboardAddon, Base64 } from '@xterm/addon-clipboard';
@@ -177,12 +177,13 @@ if (typeof window !== 'undefined') {
177
  }
178
 
179
  export default function TerminalPane({
180
- session, cli, theme, focused, active, zoom = 100, dragId, isMobile, onDragActive, onFocus, onRename, onClose,
181
  }: {
182
  session: Session;
183
  cli?: Cli;
184
  theme: 'light' | 'dark';
185
  focused?: boolean;
 
186
  active?: boolean;
187
  zoom?: number;
188
  dragId?: string; // set when the pane can be rearranged (group view)
@@ -196,6 +197,7 @@ export default function TerminalPane({
196
  const frameRef = useRef<HTMLDivElement>(null);
197
  const termRef = useRef<Terminal | null>(null);
198
  const resyncRef = useRef<() => void>(() => {});
 
199
  const claimRef = useRef<() => void>(() => {});
200
  const reconnectRef = useRef<() => void>(() => {});
201
  const controllerRef = useRef(false);
@@ -576,6 +578,21 @@ export default function TerminalPane({
576
  resyncTimer = setTimeout(() => { resyncTimer = null; requestSize(); }, 80);
577
  };
578
  resyncRef.current = resync;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
579
  const onReturn = () => resync();
580
  const onVisible = () => { if (!document.hidden) onReturn(); };
581
  // Typing means the user sees enough to interact — drop the boot cover.
@@ -723,6 +740,7 @@ export default function TerminalPane({
723
  term.dispose();
724
  termRef.current = null;
725
  resyncRef.current = () => {};
 
726
  claimRef.current = () => {};
727
  };
728
  }, [session.id]);
@@ -749,6 +767,14 @@ export default function TerminalPane({
749
  if (active) resyncRef.current();
750
  }, [zoom, active]);
751
 
 
 
 
 
 
 
 
 
752
  // Move keyboard focus into the terminal whenever this pane becomes the active
753
  // one (e.g. selected from the sidebar, or newly created).
754
  useEffect(() => {
 
1
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
  import { Terminal, type ITheme } from '@xterm/xterm';
3
  import { FitAddon } from '@xterm/addon-fit';
4
  import { ClipboardAddon, Base64 } from '@xterm/addon-clipboard';
 
177
  }
178
 
179
  export default function TerminalPane({
180
+ session, cli, theme, focused, visible, active, zoom = 100, dragId, isMobile, onDragActive, onFocus, onRename, onClose,
181
  }: {
182
  session: Session;
183
  cli?: Cli;
184
  theme: 'light' | 'dark';
185
  focused?: boolean;
186
+ visible?: boolean;
187
  active?: boolean;
188
  zoom?: number;
189
  dragId?: string; // set when the pane can be rearranged (group view)
 
197
  const frameRef = useRef<HTMLDivElement>(null);
198
  const termRef = useRef<Terminal | null>(null);
199
  const resyncRef = useRef<() => void>(() => {});
200
+ const reconcileScrollRef = useRef<() => void>(() => {});
201
  const claimRef = useRef<() => void>(() => {});
202
  const reconnectRef = useRef<() => void>(() => {});
203
  const controllerRef = useRef(false);
 
578
  resyncTimer = setTimeout(() => { resyncTimer = null; requestSize(); }, 80);
579
  };
580
  resyncRef.current = resync;
581
+ // xterm deliberately ignores DOM scroll events while its viewport is under
582
+ // display:none. Output can still advance its logical viewport in that
583
+ // state, leaving scrollTop behind; the next wheel then calculates a large
584
+ // jump from two contradictory positions. Move away and back through the
585
+ // public scroll API once the pane has layout again. This makes xterm rebuild
586
+ // its scroll area and reconcile scrollTop without changing where the user
587
+ // was reading.
588
+ reconcileScrollRef.current = () => {
589
+ const box = hostRef.current;
590
+ const buffer = term.buffer.active;
591
+ if (!box || box.clientWidth < 40 || box.clientHeight < 40 || buffer.baseY <= 0) return;
592
+ const step = buffer.viewportY > 0 ? -1 : 1;
593
+ term.scrollLines(step);
594
+ term.scrollLines(-step);
595
+ };
596
  const onReturn = () => resync();
597
  const onVisible = () => { if (!document.hidden) onReturn(); };
598
  // Typing means the user sees enough to interact — drop the boot cover.
 
740
  term.dispose();
741
  termRef.current = null;
742
  resyncRef.current = () => {};
743
+ reconcileScrollRef.current = () => {};
744
  claimRef.current = () => {};
745
  };
746
  }, [session.id]);
 
767
  if (active) resyncRef.current();
768
  }, [zoom, active]);
769
 
770
+ // A retained pane can receive output while display:none. Reconcile xterm's
771
+ // logical and DOM scroll positions as soon as any cached tile becomes visible,
772
+ // including non-focused panes in a group.
773
+ useLayoutEffect(() => {
774
+ if (!visible) return;
775
+ reconcileScrollRef.current();
776
+ }, [visible]);
777
+
778
  // Move keyboard focus into the terminal whenever this pane becomes the active
779
  // one (e.g. selected from the sidebar, or newly created).
780
  useEffect(() => {