Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions galata/test/jupyterlab/console.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,163 @@
await expect(executedCell).toContainText('4194304');
});
});

test.describe('Console Input Auto-Resize', () => {
test.beforeEach(async ({ page }) => setupConsole(page));

test('Input prompt auto-resizes with multiple lines of text', async ({
page
}) => {
const codeConsoleInput = page.locator('.jp-CodeConsole-input');

const initialHeight = await codeConsoleInput.boundingBox();
expect(initialHeight).not.toBeNull();

const multiLineCode = `def hello_world():
print("Hello")
print("World")
return "Done"`;

await page.keyboard.type(multiLineCode);

const afterTypingHeight = await codeConsoleInput.boundingBox();
expect(afterTypingHeight).not.toBeNull();
expect(afterTypingHeight!.height).toBeGreaterThan(initialHeight!.height);
});

test('Input prompt auto-resize works with paste operations', async ({
page
}) => {
const codeConsoleInput = page.locator('.jp-CodeConsole-input');

const initialHeight = await codeConsoleInput.boundingBox();
expect(initialHeight).not.toBeNull();

const pastedCode = `import numpy as np
import pandas as pd
data = pd.DataFrame({
'x': [1, 2, 3, 4, 5],
'y': [2, 4, 6, 8, 10]
})
print(data.head())`;

await page.evaluate(async code => {
await navigator.clipboard.writeText(code);
}, pastedCode);

await page.keyboard.press('ControlOrMeta+v');

const afterPasteHeight = await codeConsoleInput.boundingBox();
expect(afterPasteHeight).not.toBeNull();
expect(afterPasteHeight!.height).toBeGreaterThan(initialHeight!.height);

Check failure on line 125 in galata/test/jupyterlab/console.test.ts

View workflow job for this annotation

GitHub Actions / Visual Regression Tests

[jupyterlab] › test/jupyterlab/console.test.ts:99:7 › Console Input Auto-Resize › Input prompt auto-resize works with paste operations

2) [jupyterlab] › test/jupyterlab/console.test.ts:99:7 › Console Input Auto-Resize › Input prompt auto-resize works with paste operations Error: expect(received).toBeGreaterThan(expected) Expected: > 52 Received: 52 123 | const afterPasteHeight = await codeConsoleInput.boundingBox(); 124 | expect(afterPasteHeight).not.toBeNull(); > 125 | expect(afterPasteHeight!.height).toBeGreaterThan(initialHeight!.height); | ^ 126 | }); 127 | 128 | test('Input prompt maintains auto-resize height when moved from bottom to top', async ({ at /home/runner/work/jupyterlab/jupyterlab/galata/test/jupyterlab/console.test.ts:125:38
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this is flaky as per GitHub annotations?

});

test('Input prompt maintains auto-resize height when moved from bottom to top', async ({
page
}) => {
const codeConsoleInput = page.locator('.jp-CodeConsole-input');

const pastedCode = `def complex_function():
for i in range(10):
if i % 2 == 0:
print(f"Even: {i}")
else:
print(f"Odd: {i}")
return "Completed"`;

await page.evaluate(async code => {
await navigator.clipboard.writeText(code);
}, pastedCode);

await page.keyboard.press('ControlOrMeta+v');

const heightAtBottom = await codeConsoleInput.boundingBox();
expect(heightAtBottom).not.toBeNull();

await page.getByLabel('Change Console Prompt Position').first().click();
await page.getByText('Prompt to top').click();

const heightAtTop = await codeConsoleInput.boundingBox();
expect(heightAtTop).not.toBeNull();
expect(heightAtTop!.height).toBe(heightAtBottom!.height);
});

test('Input prompt continues to auto-resize after code execution', async ({
page
}) => {
const codeConsoleInput = page.locator('.jp-CodeConsole-input');

const initialHeight = await codeConsoleInput.boundingBox();
expect(initialHeight).not.toBeNull();

const multiLineCode = `def test_function():
print("Line 1")
print("Line 2")
return "Done"`;

await page.keyboard.type(multiLineCode);

const heightBeforeExecution = await codeConsoleInput.boundingBox();
expect(heightBeforeExecution).not.toBeNull();
expect(heightBeforeExecution!.height).toBeGreaterThan(
initialHeight!.height
);

// Execute the code
await page.keyboard.press('Shift+Enter');

await page.locator('text=| Idle').waitFor();

// Check that the new empty input cell has shrunk back to original size
const heightAfterExecution = await codeConsoleInput.boundingBox();
expect(heightAfterExecution).not.toBeNull();
expect(heightAfterExecution!.height).toBe(initialHeight!.height);

// Type new multi-line code in the new prompt cell
const moreCode = `import os
import sys
print("Testing auto-resize")
print("After execution")`;

await page.keyboard.type(moreCode);

const heightAfterTyping = await codeConsoleInput.boundingBox();
expect(heightAfterTyping).not.toBeNull();

// The input should have grown again for the new multi-line content
expect(heightAfterTyping!.height).toBeGreaterThan(
heightAfterExecution!.height
);
});

test('Input prompt shrinks when content is cleared', async ({ page }) => {
const codeConsoleInput = page.locator('.jp-CodeConsole-input');

const initialHeight = await codeConsoleInput.boundingBox();
expect(initialHeight).not.toBeNull();

const multiLineCode = `def multi_line_function():
print("This is line 1")
print("This is line 2")
print("This is line 3")
for i in range(5):
print(f"Loop iteration {i}")
return "Finished"`;

await page.keyboard.type(multiLineCode);

const expandedHeight = await codeConsoleInput.boundingBox();
expect(expandedHeight).not.toBeNull();
expect(expandedHeight!.height).toBeGreaterThan(initialHeight!.height);

// Clear the input using Ctrl+A followed by Delete
await page.keyboard.press('ControlOrMeta+a');
await page.keyboard.press('Delete');

const shrunkHeight = await codeConsoleInput.boundingBox();
expect(shrunkHeight).not.toBeNull();
expect(shrunkHeight!.height).toBe(initialHeight!.height);
});
});
135 changes: 127 additions & 8 deletions packages/console/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ export class CodeConsole extends Widget {

layout.addWidget(this._splitPanel);

// Listen for manual split panel resizing
this._splitPanel.handleMoved.connect(() => {
this._hasManualResize = true;
}, this);

// initialize the console with defaults
this.setConfig({
clearCellsOnExecute: false,
Expand Down Expand Up @@ -317,6 +322,16 @@ export class CodeConsole extends Widget {
if (this.isDisposed) {
return;
}

// Clean up ResizeObserver from the current prompt cell
const promptCell = this.promptCell;
if (promptCell) {
if (this._promptResizeObserver) {
this._promptResizeObserver.disconnect();
this._promptResizeObserver = null;
}
}

this._msgIdCells = null!;
this._msgIds = null!;
this._history.dispose();
Expand Down Expand Up @@ -675,9 +690,14 @@ export class CodeConsole extends Widget {
// the `readOnly` configuration gets updated before editor signals
// get disconnected (see `Cell.onUpdateRequest`).
const oldCell = promptCell;
const promptResizeObserver = this._promptResizeObserver;
requestIdleCallback(() => {
// Clear the signals to avoid memory leaks
Signal.clearData(oldCell.editor);

if (promptResizeObserver) {
promptResizeObserver.disconnect();
}
});

// Ensure to clear the cursor
Expand All @@ -700,8 +720,24 @@ export class CodeConsole extends Widget {
// Add the prompt cell to the DOM, making `this.promptCell` valid again.
this._input.addWidget(promptCell);

// Reset input size to default (unless the split has manually been resized)
this._resetInputSize();

this._history.editor = promptCell.editor;

// Detect height changes
if (promptCell.node) {
this._promptResizeObserver = new ResizeObserver(() => {
if (!this._hasManualResize) {
this._resetInputSize();
}
requestAnimationFrame(() => {
this._adjustSplitPanelForInputGrowth();
});
Comment on lines +734 to +736
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to delay this until next frame? I am not sure if this is the exact source of the issue, but it looks like each time I press enter a really visual jitter is present

jitter.webm

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wow, I haven't experienced such jitter locally. Which browser is it?

iirc the delay was needed to be able to pick up the correct size. But I can try to take another look at it to double check.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's Chrome.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried it on Binder, and I’m noticing the same jitter on Brave browser as well.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK I could reproduce locally with Chrome 141:

jupyterlab-console-jitter.mp4

});
this._promptResizeObserver.observe(promptCell.node);
}

Comment on lines +728 to +740
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about any performance considerations or what the previous implementation was like, but would this cause a lot of redraws unless we debounce it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the previous implementation was just using flex, so it was offloaded to the browser, but otherwise, yeah, I was thinking about adding some debouncing to this.

The main idea is that we need to catch any change to the height of the prompt cell, which happens most of the time when a user adds a new line.

if (!this._config.clearCodeContentOnExecute) {
promptCell.model.sharedModel.setSource(previousContent);
if (previousCursorPosition) {
Expand Down Expand Up @@ -943,12 +979,95 @@ export class CodeConsole extends Widget {
this.node.classList.toggle(READ_WRITE_CLASS, inReadWrite);
}

/**
* Calculate relative sizes for split panel based on prompt cell position.
*/
private _calculateRelativeSizes(): number[] {
const { promptCellPosition = 'bottom' } = this._config;

let sizes = [1, 1];
if (promptCellPosition === 'top') {
sizes = [1, 100];
} else if (promptCellPosition === 'bottom') {
sizes = [100, 1];
}
return sizes;
}

/**
* Reset input area size to default when new prompt is created.
*/
private _resetInputSize(): void {
if (this._hasManualResize) {
return;
}

const { promptCellPosition = 'bottom' } = this._config;

// Only reset for vertical layouts (top/bottom positions)
if (promptCellPosition === 'left' || promptCellPosition === 'right') {
return;
}

this._splitPanel.setRelativeSizes(this._calculateRelativeSizes());
}

/**
* Adjust split panel sizes when the input cell grows or shrinks.
*/
private _adjustSplitPanelForInputGrowth(): void {
if (!this._input.node || !this._content.node || this._hasManualResize) {
return;
}

const { promptCellPosition = 'bottom' } = this._config;

// Only adjust for vertical layouts (top/bottom positions)
if (promptCellPosition === 'left' || promptCellPosition === 'right') {
return;
}

const inputHeight = this._input.node.scrollHeight;
const totalHeight = this._splitPanel.node.clientHeight;

if (totalHeight <= 0 || inputHeight <= 0) {
this._splitPanel.fit();
return;
}

const remainingHeight = totalHeight - inputHeight;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, what would happen if inputHeight exceeds totalHeight, i.e., for very tall content? Should we have something like const maxInputHeight = Math.min(inputHeight, totalHeight * 0.8) here, or does SplitPanel clamp negative values to 0?

If yes, we could also consider if we want to refactor this math into a _calculateSizes function or similar, as I think _adjustSplitPanelForInputGrowth alone is doing quite a lot :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normally it should not be possible for inputHeight to be greater than totalHeight, since totalHeight corresponds to the height of the SplitPanel and the input widget is one of the splits.

let contentRatio: number;
let inputRatio: number;

if (promptCellPosition === 'bottom') {
contentRatio = remainingHeight / totalHeight;
inputRatio = inputHeight / totalHeight;
} else {
inputRatio = inputHeight / totalHeight;
contentRatio = remainingHeight / totalHeight;
}

// Convert to the format expected by setRelativeSizes
const totalRatio = contentRatio + inputRatio;
if (totalRatio > 0) {
const normalizedSizes =
promptCellPosition === 'bottom'
? [contentRatio / totalRatio, inputRatio / totalRatio]
: [inputRatio / totalRatio, contentRatio / totalRatio];

this._splitPanel.setRelativeSizes(normalizedSizes);
}
}

/**
* Update the layout of the code console.
*/
private _updateLayout(): void {
const { promptCellPosition = 'bottom' } = this._config;

// Reset manual resize flag when layout changes
this._hasManualResize = false;

this._splitPanel.orientation = ['left', 'right'].includes(
promptCellPosition
)
Expand All @@ -967,14 +1086,12 @@ export class CodeConsole extends Widget {
this._splitPanel.insertWidget(1, this._content);
}

// Default relative sizes
let sizes = [1, 1];
if (promptCellPosition === 'top') {
sizes = [1, 100];
} else if (promptCellPosition === 'bottom') {
sizes = [100, 1];
}
this._splitPanel.setRelativeSizes(sizes);
this._splitPanel.setRelativeSizes(this._calculateRelativeSizes());

requestAnimationFrame(() => {
// adjust the sizes if the prompt cell is moved with code in it
this._adjustSplitPanelForInputGrowth();
});
}

private _banner: RawCell | null = null;
Expand All @@ -999,6 +1116,8 @@ export class CodeConsole extends Widget {
private _focusedCell: Cell | null = null;
private _translator: ITranslator;
private _splitPanel: SplitPanel;
private _promptResizeObserver: ResizeObserver | null = null;
private _hasManualResize = false;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
private _hasManualResize = false;
private _hasManualResize: boolean = false;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^ this is not needed as typescript can infer this type

}

/**
Expand Down
Loading