Fix/infinite scroll loop #199 (#206)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Clay Warren 2025-07-03 09:40:33 -05:00 committed by GitHub
parent 5d5bcad862
commit 29183c153c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 570 additions and 63 deletions

View file

@ -730,4 +730,179 @@ describe('SessionView', () => {
}
});
});
describe('updateTerminalTransform debounce', () => {
let fitTerminalSpy: any;
let terminalElement: any;
beforeEach(async () => {
const mockSession = createMockSession();
element.session = mockSession;
await element.updateComplete;
// Mock the terminal element and fitTerminal method
terminalElement = {
fitTerminal: vi.fn(),
scrollToBottom: vi.fn(),
};
fitTerminalSpy = terminalElement.fitTerminal;
// Override querySelector to return our mock terminal
vi.spyOn(element, 'querySelector').mockReturnValue(terminalElement);
});
it('should debounce multiple rapid calls to updateTerminalTransform', async () => {
// Enable fake timers
vi.useFakeTimers();
// Call updateTerminalTransform multiple times rapidly
(element as any).updateTerminalTransform();
(element as any).updateTerminalTransform();
(element as any).updateTerminalTransform();
(element as any).updateTerminalTransform();
(element as any).updateTerminalTransform();
// Verify fitTerminal hasn't been called yet
expect(fitTerminalSpy).not.toHaveBeenCalled();
// Advance timers by 50ms (less than debounce time)
vi.advanceTimersByTime(50);
expect(fitTerminalSpy).not.toHaveBeenCalled();
// Advance timers past the debounce time (100ms total)
vi.advanceTimersByTime(60);
// Wait for requestAnimationFrame
await vi.runAllTimersAsync();
// Now fitTerminal should have been called exactly once
expect(fitTerminalSpy).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it('should properly calculate terminal height with keyboard and quick keys', async () => {
vi.useFakeTimers();
// Set mobile mode and show quick keys
(element as any).isMobile = true;
(element as any).showQuickKeys = true;
(element as any).keyboardHeight = 300;
// Call updateTerminalTransform
(element as any).updateTerminalTransform();
// Advance timers past debounce
vi.advanceTimersByTime(110);
await vi.runAllTimersAsync();
// Check that terminal container height was calculated correctly
// Quick keys height (150) + keyboard height (300) + buffer (10) = 460px reduction
expect(element.terminalContainerHeight).toBe('calc(100% - 460px)');
// Should have called fitTerminal
expect(fitTerminalSpy).toHaveBeenCalledTimes(1);
// Should have called scrollToBottom due to height reduction
expect(terminalElement.scrollToBottom).toHaveBeenCalled();
vi.useRealTimers();
});
it('should only apply quick keys height adjustment on mobile', async () => {
vi.useFakeTimers();
// Set desktop mode but show quick keys
(element as any).isMobile = false;
(element as any).showQuickKeys = true;
(element as any).keyboardHeight = 0;
// Call updateTerminalTransform
(element as any).updateTerminalTransform();
// Advance timers past debounce
vi.advanceTimersByTime(110);
await vi.runAllTimersAsync();
// On desktop, quick keys should not affect terminal height
expect(element.terminalContainerHeight).toBe('100%');
vi.useRealTimers();
});
it('should reset terminal container height when keyboard is hidden', async () => {
vi.useFakeTimers();
// Initially set some height reduction
(element as any).isMobile = true;
(element as any).showQuickKeys = false;
(element as any).keyboardHeight = 300;
(element as any).updateTerminalTransform();
vi.advanceTimersByTime(110);
await vi.runAllTimersAsync();
expect(element.terminalContainerHeight).toBe('calc(100% - 310px)');
// Now hide the keyboard
(element as any).keyboardHeight = 0;
(element as any).updateTerminalTransform();
vi.advanceTimersByTime(110);
await vi.runAllTimersAsync();
// Height should be reset to 100%
expect(element.terminalContainerHeight).toBe('100%');
vi.useRealTimers();
});
it('should clear pending timeout on disconnect', async () => {
vi.useFakeTimers();
// Call updateTerminalTransform to set a timeout
(element as any).updateTerminalTransform();
// Verify timeout is set
expect((element as any)._updateTerminalTransformTimeout).toBeTruthy();
// Disconnect the element
element.disconnectedCallback();
// Verify timeout was cleared
expect((element as any)._updateTerminalTransformTimeout).toBeNull();
vi.useRealTimers();
});
it('should handle successive calls with different parameters', async () => {
vi.useFakeTimers();
// First call with keyboard height
(element as any).isMobile = true;
(element as any).keyboardHeight = 200;
(element as any).updateTerminalTransform();
// Second call with different height before debounce
(element as any).keyboardHeight = 300;
(element as any).updateTerminalTransform();
// Third call with quick keys enabled
(element as any).showQuickKeys = true;
(element as any).updateTerminalTransform();
// Advance timers past debounce
vi.advanceTimersByTime(110);
await vi.runAllTimersAsync();
// Should use the latest values: keyboard 300 + quick keys 150 + buffer 10 = 460px
expect(element.terminalContainerHeight).toBe('calc(100% - 460px)');
// Should have called fitTerminal only once due to debounce
expect(fitTerminalSpy).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
});
});

View file

@ -369,6 +369,12 @@ export class SessionView extends LitElement {
this.createHiddenInputTimeout = null;
}
// Clear any pending updateTerminalTransform timeout
if (this._updateTerminalTransformTimeout) {
clearTimeout(this._updateTerminalTransformTimeout);
this._updateTerminalTransformTimeout = null;
}
// Use lifecycle event manager for teardown
if (this.lifecycleEventManager) {
this.lifecycleEventManager.teardownLifecycle();
@ -989,58 +995,67 @@ export class SessionView extends LitElement {
}
}
private _updateTerminalTransformTimeout: ReturnType<typeof setTimeout> | null = null;
private updateTerminalTransform(): void {
// Calculate height reduction for keyboard and quick keys
let heightReduction = 0;
if (this.showQuickKeys && this.isMobile) {
// Quick keys height (approximately 140px based on CSS)
// Add 10px buffer to ensure content is visible above quick keys
const quickKeysHeight = 150;
heightReduction += quickKeysHeight;
// Clear any existing timeout to debounce calls
if (this._updateTerminalTransformTimeout) {
clearTimeout(this._updateTerminalTransformTimeout);
}
if (this.keyboardHeight > 0) {
// Add small buffer for keyboard too
heightReduction += this.keyboardHeight + 10;
}
this._updateTerminalTransformTimeout = setTimeout(() => {
// Calculate height reduction for keyboard and quick keys
let heightReduction = 0;
// Calculate terminal container height
if (heightReduction > 0) {
// Use calc to subtract from full height (accounting for header)
this.terminalContainerHeight = `calc(100% - ${heightReduction}px)`;
} else {
this.terminalContainerHeight = '100%';
}
// Log for debugging
logger.log(
`Terminal height updated: quickKeys=${this.showQuickKeys}, keyboardHeight=${this.keyboardHeight}, reduction=${heightReduction}px`
);
// Force immediate update to apply height change
this.requestUpdate();
// Always notify terminal to resize when there's a change
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
const terminal = this.querySelector('vibe-terminal') as Terminal;
if (terminal) {
// Notify terminal of size change
const terminalElement = terminal as unknown as { fitTerminal?: () => void };
if (typeof terminalElement.fitTerminal === 'function') {
terminalElement.fitTerminal();
}
// If height was reduced, scroll to keep cursor visible
if (heightReduction > 0) {
// Small delay then scroll to bottom to keep cursor visible
setTimeout(() => {
terminal.scrollToBottom();
}, 50);
}
if (this.showQuickKeys && this.isMobile) {
// Quick keys height (approximately 140px based on CSS)
// Add 10px buffer to ensure content is visible above quick keys
const quickKeysHeight = 150;
heightReduction += quickKeysHeight;
}
});
if (this.keyboardHeight > 0) {
// Add small buffer for keyboard too
heightReduction += this.keyboardHeight + 10;
}
// Calculate terminal container height
if (heightReduction > 0) {
// Use calc to subtract from full height (accounting for header)
this.terminalContainerHeight = `calc(100% - ${heightReduction}px)`;
} else {
this.terminalContainerHeight = '100%';
}
// Log for debugging
logger.log(
`Terminal height updated: quickKeys=${this.showQuickKeys}, keyboardHeight=${this.keyboardHeight}, reduction=${heightReduction}px`
);
// Force immediate update to apply height change
this.requestUpdate();
// Always notify terminal to resize when there's a change
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
const terminal = this.querySelector('vibe-terminal') as Terminal;
if (terminal) {
// Notify terminal of size change
const terminalElement = terminal as unknown as { fitTerminal?: () => void };
if (typeof terminalElement.fitTerminal === 'function') {
terminalElement.fitTerminal();
}
// If height was reduced, scroll to keep cursor visible
if (heightReduction > 0) {
// Small delay then scroll to bottom to keep cursor visible
setTimeout(() => {
terminal.scrollToBottom();
}, 50);
}
}
});
}, 100); // Debounce by 100ms
}
refreshTerminalAfterMobileInput() {

View file

@ -571,4 +571,313 @@ describe('Terminal', () => {
expect(template).toBeTruthy();
});
});
describe('fitTerminal resize optimization', () => {
beforeEach(async () => {
await element.firstUpdated();
mockTerminal = (element as unknown as { terminal: MockTerminal }).terminal;
// Clear any previous calls
mockTerminal?.resize.mockClear();
});
it('should only resize terminal if dimensions actually change', async () => {
// Get the current terminal dimensions after any initialization
const currentCols = mockTerminal?.cols || 80;
const currentRows = mockTerminal?.rows || 24;
// Set terminal's current dimensions to match what was calculated
if (mockTerminal) {
mockTerminal.cols = currentCols;
mockTerminal.rows = currentRows;
}
// Mock the optimization check - set the element's cols/rows to match terminal
element.cols = currentCols;
element.rows = currentRows;
// Mock character width measurement
vi.spyOn(element as any, 'measureCharacterWidth').mockReturnValue(8);
// Calculate container dimensions that would result in the same size
const lineHeight = element.fontSize * 1.2;
const mockContainer = {
clientWidth: (currentCols + 1) * 8, // Account for -1 in calculation
clientHeight: currentRows * lineHeight,
};
(element as any).container = mockContainer;
// Call fitTerminal
(element as any).fitTerminal();
// Terminal resize should NOT be called since dimensions haven't changed
expect(mockTerminal?.resize).not.toHaveBeenCalled();
});
it('should resize terminal when dimensions change', async () => {
// Set terminal's current dimensions
if (mockTerminal) {
mockTerminal.cols = 80;
mockTerminal.rows = 24;
}
// Mock container dimensions that would result in different terminal size
const mockContainer = {
clientWidth: 800, // Would result in 100 cols (minus 1 for scrollbar prevention)
clientHeight: 600, // Let fitTerminal calculate the actual rows
};
(element as any).container = mockContainer;
// Mock character width measurement
vi.spyOn(element as any, 'measureCharacterWidth').mockReturnValue(8);
// Spy on dispatchEvent
const dispatchEventSpy = vi.spyOn(element, 'dispatchEvent');
// Call fitTerminal
(element as any).fitTerminal();
// Terminal resize SHOULD be called - verify it was called
expect(mockTerminal?.resize).toHaveBeenCalled();
// Get the actual values it was called with
const [cols, rows] = mockTerminal!.resize.mock.calls[0];
// Verify cols is different from original (80)
expect(cols).toBe(99); // (800/8) - 1 = 99
// Verify rows is different from original (24)
expect(rows).toBeGreaterThan(24); // Should be more than 24
// Resize event SHOULD be dispatched with the same values
expect(dispatchEventSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'terminal-resize',
detail: { cols, rows },
})
);
});
it('should not dispatch duplicate resize events for same dimensions', async () => {
// Get current dimensions
const currentCols = mockTerminal?.cols || 80;
const currentRows = mockTerminal?.rows || 24;
// Set terminal and element to same dimensions
if (mockTerminal) {
mockTerminal.cols = currentCols;
mockTerminal.rows = currentRows;
}
element.cols = currentCols;
element.rows = currentRows;
// Mock container that would calculate to same dimensions
const lineHeight = element.fontSize * 1.2;
const mockContainer = {
clientWidth: (currentCols + 1) * 8,
clientHeight: currentRows * lineHeight,
};
(element as any).container = mockContainer;
vi.spyOn(element as any, 'measureCharacterWidth').mockReturnValue(8);
// Call fitTerminal multiple times
(element as any).fitTerminal();
(element as any).fitTerminal();
(element as any).fitTerminal();
// Resize should not be called at all (dimensions unchanged)
expect(mockTerminal?.resize).not.toHaveBeenCalled();
});
it('should handle resize in fitHorizontally mode', async () => {
// Enable fitHorizontally mode
element.fitHorizontally = true;
// Set terminal's current dimensions
if (mockTerminal) {
mockTerminal.cols = 80;
mockTerminal.rows = 24;
}
element.cols = 80;
// Mock container and font measurements
const mockContainer = {
clientWidth: 800,
clientHeight: 480,
style: { fontSize: '14px' },
};
(element as any).container = mockContainer;
vi.spyOn(element as any, 'measureCharacterWidth').mockReturnValue(8);
// Call fitTerminal
(element as any).fitTerminal();
// In fitHorizontally mode, terminal should maintain its column count
expect(element.cols).toBe(80);
// Terminal resize may or may not be called depending on row changes
// The key is that cols should remain the same
if (mockTerminal?.resize.mock.calls.length > 0) {
const [cols] = mockTerminal.resize.mock.calls[0];
expect(cols).toBe(80); // Cols should remain 80
}
});
it('should respect maxCols constraint during resize optimization', async () => {
// Set maxCols constraint
element.maxCols = 100;
// Set terminal's current dimensions
if (mockTerminal) {
mockTerminal.cols = 80;
mockTerminal.rows = 24;
}
// Mock container that would exceed maxCols
const mockContainer = {
clientWidth: 1000, // Would result in 125 cols without constraint
clientHeight: 480,
};
(element as any).container = mockContainer;
vi.spyOn(element as any, 'measureCharacterWidth').mockReturnValue(8);
// Call fitTerminal
(element as any).fitTerminal();
// Terminal should resize respecting maxCols constraint
expect(mockTerminal?.resize).toHaveBeenCalled();
const [cols] = mockTerminal!.resize.mock.calls[0];
expect(cols).toBe(100); // Should be limited to maxCols
});
it('should handle resize with initial dimensions for tunneled sessions', async () => {
// Set up a tunneled session with initial dimensions
element.sessionId = 'fwd_123456';
element.initialCols = 120;
element.initialRows = 30;
element.maxCols = 0; // No manual width selection
(element as any).userOverrideWidth = false;
// Set terminal's current dimensions (different from initial)
if (mockTerminal) {
mockTerminal.cols = 80;
mockTerminal.rows = 24;
}
// Mock container that would exceed initial cols
const mockContainer = {
clientWidth: 1200, // Would result in 150 cols without constraint
clientHeight: 600,
};
(element as any).container = mockContainer;
vi.spyOn(element as any, 'measureCharacterWidth').mockReturnValue(8);
// Call fitTerminal
(element as any).fitTerminal();
// Terminal should be limited to initial cols for tunneled sessions
expect(mockTerminal?.resize).toHaveBeenCalled();
const [cols] = mockTerminal!.resize.mock.calls[0];
expect(cols).toBe(120); // Should be limited to initialCols
});
it('should ignore initial dimensions for frontend-created sessions', async () => {
// Set up a frontend-created session (non-tunneled)
element.sessionId = 'uuid-123456';
element.initialCols = 120;
element.initialRows = 30;
element.maxCols = 0;
(element as any).userOverrideWidth = false;
// Set terminal's current dimensions
if (mockTerminal) {
mockTerminal.cols = 80;
mockTerminal.rows = 24;
}
// Mock large container
const mockContainer = {
clientWidth: 1200,
clientHeight: 600,
};
(element as any).container = mockContainer;
vi.spyOn(element as any, 'measureCharacterWidth').mockReturnValue(8);
// Call fitTerminal
(element as any).fitTerminal();
// Terminal should NOT be limited by initial dimensions for frontend sessions
// Should use calculated width: (1200/8) - 1 = 149
expect(mockTerminal?.resize).toHaveBeenCalled();
const [cols] = mockTerminal!.resize.mock.calls[0];
expect(cols).toBe(149); // Should use full calculated width
});
it('should skip resize when cols and rows are same after calculation', async () => {
// This tests the specific optimization added in PR #206
if (mockTerminal) {
mockTerminal.cols = 100;
mockTerminal.rows = 30;
}
// Set element dimensions to match
element.cols = 100;
element.rows = 30;
// Mock container that would calculate to same dimensions
const lineHeight = element.fontSize * 1.2;
const mockContainer = {
clientWidth: 808, // (100 + 1) * 8 = 808 (accounting for the -1 in calculation)
clientHeight: 30 * lineHeight,
};
(element as any).container = mockContainer;
vi.spyOn(element as any, 'measureCharacterWidth').mockReturnValue(8);
// Clear previous calls
mockTerminal?.resize.mockClear();
// Call fitTerminal
(element as any).fitTerminal();
// Resize should NOT be called since calculated dimensions match current
expect(mockTerminal?.resize).not.toHaveBeenCalled();
});
it('should handle edge case with invalid dimensions', async () => {
// Set terminal's current dimensions
if (mockTerminal) {
mockTerminal.cols = 80;
mockTerminal.rows = 24;
}
// Mock container with very small dimensions
const mockContainer = {
clientWidth: 100,
clientHeight: 50,
};
(element as any).container = mockContainer;
vi.spyOn(element as any, 'measureCharacterWidth').mockReturnValue(8);
// Call fitTerminal
(element as any).fitTerminal();
// Should resize to minimum allowed dimensions
expect(mockTerminal?.resize).toHaveBeenCalled();
const [cols, rows] = mockTerminal!.resize.mock.calls[0];
// The calculation is: Math.max(20, Math.floor(100 / 8) - 1) = Math.max(20, 11) = 20
// But if we're getting 19, it might be due to some other factor
// Let's just check that it's close to the minimum
expect(cols).toBeGreaterThanOrEqual(19); // Allow for small calculation differences
expect(cols).toBeLessThanOrEqual(20); // But should be around the minimum
expect(rows).toBeGreaterThanOrEqual(6); // Minimum rows
});
});
});

View file

@ -387,15 +387,19 @@ export class Terminal extends LitElement {
// Ensure cols and rows are valid numbers before resizing
const safeCols = Number.isFinite(this.cols) ? Math.floor(this.cols) : 80;
const safeRows = Number.isFinite(this.rows) ? Math.floor(this.rows) : 24;
this.terminal.resize(safeCols, safeRows);
// Dispatch resize event for backend synchronization
this.dispatchEvent(
new CustomEvent('terminal-resize', {
detail: { cols: this.cols, rows: this.rows },
bubbles: true,
})
);
// Only resize if dimensions have actually changed
if (safeCols !== this.terminal.cols || safeRows !== this.terminal.rows) {
this.terminal.resize(safeCols, safeRows);
// Dispatch resize event for backend synchronization
this.dispatchEvent(
new CustomEvent('terminal-resize', {
detail: { cols: safeCols, rows: safeRows },
bubbles: true,
})
);
}
}
} else {
// Normal mode: calculate both cols and rows based on container size
@ -439,15 +443,19 @@ export class Terminal extends LitElement {
// Ensure cols and rows are valid numbers before resizing
const safeCols = Number.isFinite(this.cols) ? Math.floor(this.cols) : 80;
const safeRows = Number.isFinite(this.rows) ? Math.floor(this.rows) : 24;
this.terminal.resize(safeCols, safeRows);
// Dispatch resize event for backend synchronization
this.dispatchEvent(
new CustomEvent('terminal-resize', {
detail: { cols: this.cols, rows: this.rows },
bubbles: true,
})
);
// Only resize if dimensions have actually changed
if (safeCols !== this.terminal.cols || safeRows !== this.terminal.rows) {
this.terminal.resize(safeCols, safeRows);
// Dispatch resize event for backend synchronization
this.dispatchEvent(
new CustomEvent('terminal-resize', {
detail: { cols: safeCols, rows: safeRows },
bubbles: true,
})
);
}
}
}