Spaces:
Running
Running
Leandro von Werra Agent Manager commited on
Fix retained terminal scroll after activation (#28)
Browse filesCo-authored-by: Agent Manager <agents@agent-manager.local>
- server/mobile.test.mjs +41 -2
- web/src/App.tsx +1 -0
- web/src/components/TerminalPane.tsx +28 -2
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-
|
| 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-
|
| 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(() => {
|